blob: d1875bf36b6b3e0e661867dfbc7e7a09bf0e31cd [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"
Josh Bleecher Snyder7f18fb62025-07-30 18:12:29 -070028 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070029 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070030 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070031 "sketch.dev/llm/conversation"
Philip Zeyliger194bfa82025-06-24 06:03:06 -070032 "sketch.dev/mcp"
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070033 "sketch.dev/skabandclient"
Philip Zeyliger5f26a342025-07-04 01:30:29 +000034 "tailscale.com/portlist"
Earl Lee2e463fb2025-04-17 11:22:22 -070035)
36
37const (
38 userCancelMessage = "user requested agent to stop handling responses"
39)
40
Philip Zeyligerb7c58752025-05-01 10:10:17 -070041type MessageIterator interface {
42 // Next blocks until the next message is available. It may
43 // return nil if the underlying iterator context is done.
44 Next() *AgentMessage
45 Close()
46}
47
Earl Lee2e463fb2025-04-17 11:22:22 -070048type CodingAgent interface {
49 // Init initializes an agent inside a docker container.
50 Init(AgentInit) error
51
52 // Ready returns a channel closed after Init successfully called.
53 Ready() <-chan struct{}
54
55 // URL reports the HTTP URL of this agent.
56 URL() string
57
58 // UserMessage enqueues a message to the agent and returns immediately.
59 UserMessage(ctx context.Context, msg string)
60
Philip Zeyligerb7c58752025-05-01 10:10:17 -070061 // Returns an iterator that finishes when the context is done and
62 // starts with the given message index.
63 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070064
Philip Zeyligereab12de2025-05-14 02:35:53 +000065 // Returns an iterator that notifies of state transitions until the context is done.
66 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
67
Earl Lee2e463fb2025-04-17 11:22:22 -070068 // Loop begins the agent loop returns only when ctx is cancelled.
69 Loop(ctx context.Context)
70
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000071 // BranchPrefix returns the configured branch prefix
72 BranchPrefix() string
73
philip.zeyliger6d3de482025-06-10 19:38:14 -070074 // LinkToGitHub returns whether GitHub branch linking is enabled
75 LinkToGitHub() bool
76
Sean McCulloughedc88dc2025-04-30 02:55:01 +000077 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070078
79 CancelToolUse(toolUseID string, cause error) error
80
81 // Returns a subset of the agent's message history.
82 Messages(start int, end int) []AgentMessage
83
84 // Returns the current number of messages in the history
85 MessageCount() int
86
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070087 TotalUsage() conversation.CumulativeUsage
88 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070089
Earl Lee2e463fb2025-04-17 11:22:22 -070090 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000091 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070092
93 // Diff returns a unified diff of changes made since the agent was instantiated.
94 // If commit is non-nil, it shows the diff for just that specific commit.
95 Diff(commit *string) (string, error)
96
Philip Zeyliger49edc922025-05-14 09:45:45 -070097 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
98 // starts out as the commit where sketch started, but a user can move it if need
99 // be, for example in the case of a rebase. It is stored as a git tag.
100 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700101
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000102 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
103 // (Typically, this is "sketch-base")
104 SketchGitBaseRef() string
105
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700106 // Slug returns the slug identifier for this session.
107 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700108
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000109 // BranchName returns the git branch name for the conversation.
110 BranchName() string
111
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700112 // IncrementRetryNumber increments the retry number for branch naming conflicts.
113 IncrementRetryNumber()
114
Earl Lee2e463fb2025-04-17 11:22:22 -0700115 // OS returns the operating system of the client.
116 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000117
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000118 // SessionID returns the unique session identifier.
119 SessionID() string
120
philip.zeyliger8773e682025-06-11 21:36:21 -0700121 // SSHConnectionString returns the SSH connection string for the container.
122 SSHConnectionString() string
123
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000124 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700125 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000126
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000127 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
128 OutstandingLLMCallCount() int
129
130 // OutstandingToolCalls returns the names of outstanding tool calls.
131 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000132 OutsideOS() string
133 OutsideHostname() string
134 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000135 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700136
bankseancad67b02025-06-27 21:57:05 +0000137 // GitUsername returns the git user name from the agent config.
138 GitUsername() string
139
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700140 // PassthroughUpstream returns whether passthrough upstream is enabled.
141 PassthroughUpstream() bool
142
Philip Zeyliger64f60462025-06-16 13:57:10 -0700143 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
144 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000145 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
146 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700147
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700148 // IsInContainer returns true if the agent is running in a container
149 IsInContainer() bool
150 // FirstMessageIndex returns the index of the first message in the current conversation
151 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700152
153 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700154 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
155 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700156
157 // CompactConversation compacts the current conversation by generating a summary
158 // and restarting the conversation with that summary as the initial context
159 CompactConversation(ctx context.Context) error
Philip Zeyligerda623b52025-07-04 01:12:38 +0000160
Philip Zeyliger0113be52025-06-07 23:53:41 +0000161 // SkabandAddr returns the skaband address if configured
162 SkabandAddr() string
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000163
164 // GetPorts returns the cached list of open TCP ports
165 GetPorts() []portlist.Port
banksean5ab8fb82025-07-09 12:34:55 -0700166
167 // TokenContextWindow returns the TokenContextWindow size of the model the agent is using.
168 TokenContextWindow() int
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000169
170 // ModelName returns the name of the model the agent is using.
171 ModelName() string
bankseanbdc68892025-07-28 17:28:13 -0700172
173 // ExternalMessage enqueues an external message to the agent and returns immediately.
174 ExternalMessage(ctx context.Context, msg ExternalMessage) error
Earl Lee2e463fb2025-04-17 11:22:22 -0700175}
176
177type CodingAgentMessageType string
178
179const (
bankseanbdc68892025-07-28 17:28:13 -0700180 UserMessageType CodingAgentMessageType = "user"
181 AgentMessageType CodingAgentMessageType = "agent"
182 ErrorMessageType CodingAgentMessageType = "error"
183 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
184 ToolUseMessageType CodingAgentMessageType = "tool"
185 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
186 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
187 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
188 PortMessageType CodingAgentMessageType = "port" // for port monitoring events
189 SlugMessageType CodingAgentMessageType = "slug" // for slug updates
190 ExternalMessageType CodingAgentMessageType = "external" // for external notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700191
192 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
193)
194
195type AgentMessage struct {
196 Type CodingAgentMessageType `json:"type"`
197 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
198 EndOfTurn bool `json:"end_of_turn"`
199
bankseanbdc68892025-07-28 17:28:13 -0700200 Content string `json:"content"`
201 ExternalMessage *ExternalMessage `json:"external_message,omitempty"`
202 ToolName string `json:"tool_name,omitempty"`
203 ToolInput string `json:"input,omitempty"`
204 ToolResult string `json:"tool_result,omitempty"`
205 ToolError bool `json:"tool_error,omitempty"`
206 ToolCallId string `json:"tool_call_id,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700207
208 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
209 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
210
Sean McCulloughd9f13372025-04-21 15:08:49 -0700211 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
212 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
213
Earl Lee2e463fb2025-04-17 11:22:22 -0700214 // Commits is a list of git commits for a commit message
215 Commits []*GitCommit `json:"commits,omitempty"`
216
217 Timestamp time.Time `json:"timestamp"`
218 ConversationID string `json:"conversation_id"`
219 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700220 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700221
222 // Message timing information
223 StartTime *time.Time `json:"start_time,omitempty"`
224 EndTime *time.Time `json:"end_time,omitempty"`
225 Elapsed *time.Duration `json:"elapsed,omitempty"`
226
227 // Turn duration - the time taken for a complete agent turn
228 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
229
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000230 // HideOutput indicates that this message should not be rendered in the UI.
231 // This is useful for subconversations that generate output that shouldn't be shown to the user.
232 HideOutput bool `json:"hide_output,omitempty"`
233
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700234 // TodoContent contains the agent's todo file content when it has changed
235 TodoContent *string `json:"todo_content,omitempty"`
236
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700237 // Display contains content to be displayed to the user, set by tools
238 Display any `json:"display,omitempty"`
239
Earl Lee2e463fb2025-04-17 11:22:22 -0700240 Idx int `json:"idx"`
241}
242
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000243// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700244func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700245 if convo == nil {
246 m.ConversationID = ""
247 m.ParentConversationID = nil
248 return
249 }
250 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000251 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700252 if convo.Parent != nil {
253 m.ParentConversationID = &convo.Parent.ID
254 }
255}
256
Earl Lee2e463fb2025-04-17 11:22:22 -0700257// GitCommit represents a single git commit for a commit message
258type GitCommit struct {
259 Hash string `json:"hash"` // Full commit hash
260 Subject string `json:"subject"` // Commit subject line
261 Body string `json:"body"` // Full commit message body
262 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
263}
264
265// ToolCall represents a single tool call within an agent message
266type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700267 Name string `json:"name"`
268 Input string `json:"input"`
269 ToolCallId string `json:"tool_call_id"`
270 ResultMessage *AgentMessage `json:"result_message,omitempty"`
271 Args string `json:"args,omitempty"`
272 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700273}
274
275func (a *AgentMessage) Attr() slog.Attr {
276 var attrs []any = []any{
277 slog.String("type", string(a.Type)),
278 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700279 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700280 if a.EndOfTurn {
281 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
282 }
283 if a.Content != "" {
284 attrs = append(attrs, slog.String("content", a.Content))
285 }
286 if a.ToolName != "" {
287 attrs = append(attrs, slog.String("tool_name", a.ToolName))
288 }
289 if a.ToolInput != "" {
290 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
291 }
292 if a.Elapsed != nil {
293 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
294 }
295 if a.TurnDuration != nil {
296 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
297 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700298 if len(a.ToolResult) > 0 {
299 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700300 }
301 if a.ToolError {
302 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
303 }
304 if len(a.ToolCalls) > 0 {
305 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
306 for i, tc := range a.ToolCalls {
307 toolCallAttrs = append(toolCallAttrs, slog.Group(
308 fmt.Sprintf("tool_call_%d", i),
309 slog.String("name", tc.Name),
310 slog.String("input", tc.Input),
311 ))
312 }
313 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
314 }
315 if a.ConversationID != "" {
316 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
317 }
318 if a.ParentConversationID != nil {
319 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
320 }
321 if a.Usage != nil && !a.Usage.IsZero() {
322 attrs = append(attrs, a.Usage.Attr())
323 }
324 // TODO: timestamp, convo ids, idx?
325 return slog.Group("agent_message", attrs...)
326}
327
328func errorMessage(err error) AgentMessage {
329 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
330 if os.Getenv(("DEBUG")) == "1" {
331 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
332 }
333
334 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
335}
336
337func budgetMessage(err error) AgentMessage {
338 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
339}
340
341// ConvoInterface defines the interface for conversation interactions
342type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700343 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700344 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700345 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700346 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700347 SendMessage(message llm.Message) (*llm.Response, error)
348 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700349 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000350 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700351 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700352 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700353 SubConvoWithHistory() *conversation.Convo
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700354 DebugJSON() ([]byte, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700355}
356
Philip Zeyligerf2872992025-05-22 10:35:28 -0700357// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700358// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700359// any time we notice we need to.
360type AgentGitState struct {
361 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700362 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700363 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000364 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700365 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700366 slug string // Human-readable session identifier
367 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700368 linesAdded int // Lines added from sketch-base to HEAD
369 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700370}
371
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700372func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700373 ags.mu.Lock()
374 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700375 if ags.slug != slug {
376 ags.retryNumber = 0
377 }
378 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700379}
380
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700381func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700382 ags.mu.Lock()
383 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700384 return ags.slug
385}
386
387func (ags *AgentGitState) IncrementRetryNumber() {
388 ags.mu.Lock()
389 defer ags.mu.Unlock()
390 ags.retryNumber++
391}
392
Philip Zeyliger64f60462025-06-16 13:57:10 -0700393func (ags *AgentGitState) DiffStats() (int, int) {
394 ags.mu.Lock()
395 defer ags.mu.Unlock()
396 return ags.linesAdded, ags.linesRemoved
397}
398
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700399// HasSeenCommits returns true if any commits have been processed
400func (ags *AgentGitState) HasSeenCommits() bool {
401 ags.mu.Lock()
402 defer ags.mu.Unlock()
403 return len(ags.seenCommits) > 0
404}
405
406func (ags *AgentGitState) RetryNumber() int {
407 ags.mu.Lock()
408 defer ags.mu.Unlock()
409 return ags.retryNumber
410}
411
412func (ags *AgentGitState) BranchName(prefix string) string {
413 ags.mu.Lock()
414 defer ags.mu.Unlock()
415 return ags.branchNameLocked(prefix)
416}
417
418func (ags *AgentGitState) branchNameLocked(prefix string) string {
419 if ags.slug == "" {
420 return ""
421 }
422 if ags.retryNumber == 0 {
423 return prefix + ags.slug
424 }
425 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700426}
427
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000428func (ags *AgentGitState) Upstream() string {
429 ags.mu.Lock()
430 defer ags.mu.Unlock()
431 return ags.upstream
432}
433
Earl Lee2e463fb2025-04-17 11:22:22 -0700434type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700435 convo ConvoInterface
436 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700437 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700438 workingDir string
439 repoRoot string // workingDir may be a subdir of repoRoot
440 url string
441 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000442 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700443 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000444 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700445 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700446 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000447 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700448 // State machine to track agent state
449 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000450 // Outside information
451 outsideHostname string
452 outsideOS string
453 outsideWorkingDir string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700454 // MCP manager for handling MCP server connections
455 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000456 // Port monitor for tracking TCP ports
457 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700458
459 // Time when the current turn started (reset at the beginning of InnerLoop)
460 startOfTurn time.Time
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +0000461 now func() time.Time // override-able, defaults to time.Now
Earl Lee2e463fb2025-04-17 11:22:22 -0700462
463 // Inbox - for messages from the user to the agent.
464 // sent on by UserMessage
465 // . e.g. when user types into the chat textarea
466 // read from by GatherMessages
467 inbox chan string
468
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000469 // protects cancelTurn
470 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700471 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000472 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700473
474 // protects following
475 mu sync.Mutex
476
477 // Stores all messages for this agent
478 history []AgentMessage
479
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700480 // Iterators add themselves here when they're ready to be notified of new messages.
481 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700482
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000483 // Track outstanding LLM call IDs
484 outstandingLLMCalls map[string]struct{}
485
486 // Track outstanding tool calls by ID with their names
487 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700488}
489
bankseanbdc68892025-07-28 17:28:13 -0700490// ExternalMessage implements CodingAgent.
491// TODO: Debounce and/or coalesce these messages so they're less disruptive to the conversation.
492func (a *Agent) ExternalMessage(ctx context.Context, msg ExternalMessage) error {
493 agentMsg := AgentMessage{
494 Type: ExternalMessageType,
495 ExternalMessage: &msg,
496 }
497 a.pushToOutbox(ctx, agentMsg)
498 a.inbox <- msg.TextContent
499 return nil
500}
501
banksean5ab8fb82025-07-09 12:34:55 -0700502// TokenContextWindow implements CodingAgent.
503func (a *Agent) TokenContextWindow() int {
504 return a.config.Service.TokenContextWindow()
505}
506
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000507// ModelName returns the name of the model the agent is using.
508func (a *Agent) ModelName() string {
509 return a.config.Model
510}
511
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700512// GetConvo returns the conversation interface for debugging purposes.
513func (a *Agent) GetConvo() ConvoInterface {
514 return a.convo
515}
516
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700517// NewIterator implements CodingAgent.
518func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
519 a.mu.Lock()
520 defer a.mu.Unlock()
521
522 return &MessageIteratorImpl{
523 agent: a,
524 ctx: ctx,
525 nextMessageIdx: nextMessageIdx,
526 ch: make(chan *AgentMessage, 100),
527 }
528}
529
530type MessageIteratorImpl struct {
531 agent *Agent
532 ctx context.Context
533 nextMessageIdx int
534 ch chan *AgentMessage
535 subscribed bool
536}
537
538func (m *MessageIteratorImpl) Close() {
539 m.agent.mu.Lock()
540 defer m.agent.mu.Unlock()
541 // Delete ourselves from the subscribers list
542 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
543 return x == m.ch
544 })
545 close(m.ch)
546}
547
548func (m *MessageIteratorImpl) Next() *AgentMessage {
549 // We avoid subscription at creation to let ourselves catch up to "current state"
550 // before subscribing.
551 if !m.subscribed {
552 m.agent.mu.Lock()
553 if m.nextMessageIdx < len(m.agent.history) {
554 msg := &m.agent.history[m.nextMessageIdx]
555 m.nextMessageIdx++
556 m.agent.mu.Unlock()
557 return msg
558 }
559 // The next message doesn't exist yet, so let's subscribe
560 m.agent.subscribers = append(m.agent.subscribers, m.ch)
561 m.subscribed = true
562 m.agent.mu.Unlock()
563 }
564
565 for {
566 select {
567 case <-m.ctx.Done():
568 m.agent.mu.Lock()
569 // Delete ourselves from the subscribers list
570 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
571 return x == m.ch
572 })
573 m.subscribed = false
574 m.agent.mu.Unlock()
575 return nil
576 case msg, ok := <-m.ch:
577 if !ok {
578 // Close may have been called
579 return nil
580 }
581 if msg.Idx == m.nextMessageIdx {
582 m.nextMessageIdx++
583 return msg
584 }
585 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
586 panic("out of order message")
587 }
588 }
589}
590
Sean McCulloughd9d45812025-04-30 16:53:41 -0700591// Assert that Agent satisfies the CodingAgent interface.
592var _ CodingAgent = &Agent{}
593
594// StateName implements CodingAgent.
595func (a *Agent) CurrentStateName() string {
596 if a.stateMachine == nil {
597 return ""
598 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000599 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700600}
601
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700602// CurrentTodoContent returns the current todo list data as JSON.
603// It returns an empty string if no todos exist.
604func (a *Agent) CurrentTodoContent() string {
605 todoPath := claudetool.TodoFilePath(a.config.SessionID)
606 content, err := os.ReadFile(todoPath)
607 if err != nil {
608 return ""
609 }
610 return string(content)
611}
612
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700613// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
614func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
615 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.
616
617IMPORTANT: 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.
618
619Please create a detailed summary that includes:
620
6211. **User's Request**: What did the user originally ask me to do? What was their goal?
622
6232. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
624
6253. **Key Technical Decisions**: What important technical choices were made during our work and why?
626
6274. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
628
6295. **Next Steps**: What still needs to be done to complete the user's request?
630
6316. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
632
633Focus 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.
634
635Reply with ONLY the summary content - no meta-commentary about creating the summary.`
636
637 userMessage := llm.UserStringMessage(msg)
638 // Use a subconversation with history to get the summary
639 // TODO: We don't have any tools here, so we should have enough tokens
640 // to capture a summary, but we may need to modify the history (e.g., remove
641 // TODO data) to save on some tokens.
642 convo := a.convo.SubConvoWithHistory()
643
644 // Modify the system prompt to provide context about the original task
645 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000646 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 -0700647
648Your 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.
649
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000650Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700651
652 resp, err := convo.SendMessage(userMessage)
653 if err != nil {
654 a.pushToOutbox(ctx, errorMessage(err))
655 return "", err
656 }
657 textContent := collectTextContent(resp)
658
659 // Restore original system prompt (though this subconvo will be discarded)
660 convo.SystemPrompt = originalSystemPrompt
661
662 return textContent, nil
663}
664
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000665// dumpMessageHistoryToTmp dumps the agent's entire message history to /tmp as JSON
666// and returns the filename
667func (a *Agent) dumpMessageHistoryToTmp(ctx context.Context) (string, error) {
668 // Create a filename based on session ID and timestamp
669 timestamp := time.Now().Format("20060102-150405")
670 filename := fmt.Sprintf("/tmp/sketch-messages-%s-%s.json", a.config.SessionID, timestamp)
671
672 // Marshal the entire message history to JSON
673 jsonData, err := json.MarshalIndent(a.history, "", " ")
674 if err != nil {
675 return "", fmt.Errorf("failed to marshal message history: %w", err)
676 }
677
678 // Write to file
Autoformatter3ad8c8d2025-07-15 21:05:23 +0000679 if err := os.WriteFile(filename, jsonData, 0o644); err != nil {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000680 return "", fmt.Errorf("failed to write message history to %s: %w", filename, err)
681 }
682
683 slog.InfoContext(ctx, "Dumped message history to file", "filename", filename, "message_count", len(a.history))
684 return filename, nil
685}
686
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700687// CompactConversation compacts the current conversation by generating a summary
688// and restarting the conversation with that summary as the initial context
689func (a *Agent) CompactConversation(ctx context.Context) error {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000690 // Dump the entire message history to /tmp as JSON before compacting
691 dumpFile, err := a.dumpMessageHistoryToTmp(ctx)
692 if err != nil {
693 slog.WarnContext(ctx, "Failed to dump message history to /tmp", "error", err)
694 // Continue with compaction even if dump fails
695 }
696
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700697 summary, err := a.generateConversationSummary(ctx)
698 if err != nil {
699 return fmt.Errorf("failed to generate conversation summary: %w", err)
700 }
701
702 a.mu.Lock()
703
704 // Get usage information before resetting conversation
705 lastUsage := a.convo.LastUsage()
706 contextWindow := a.config.Service.TokenContextWindow()
707 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
708
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000709 // Preserve cumulative usage across compaction
710 cumulativeUsage := a.convo.CumulativeUsage()
711
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700712 // Reset conversation state but keep all other state (git, working dir, etc.)
713 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000714 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700715
716 a.mu.Unlock()
717
718 // Create informative compaction message with token details
719 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
720 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
721 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
722
723 a.pushToOutbox(ctx, AgentMessage{
724 Type: CompactMessageType,
725 Content: compactionMsg,
726 })
727
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000728 // Create the message content with dump file information if available
729 var messageContent string
730 if dumpFile != "" {
731 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)
732 } else {
733 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)
734 }
735
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700736 a.pushToOutbox(ctx, AgentMessage{
737 Type: UserMessageType,
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000738 Content: messageContent,
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700739 })
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000740 a.inbox <- messageContent
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700741
742 return nil
743}
744
Earl Lee2e463fb2025-04-17 11:22:22 -0700745func (a *Agent) URL() string { return a.url }
746
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000747// GetPorts returns the cached list of open TCP ports.
748func (a *Agent) GetPorts() []portlist.Port {
749 if a.portMonitor == nil {
750 return nil
751 }
752 return a.portMonitor.GetPorts()
753}
754
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000755// BranchName returns the git branch name for the conversation.
756func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700757 return a.gitState.BranchName(a.config.BranchPrefix)
758}
759
760// Slug returns the slug identifier for this conversation.
761func (a *Agent) Slug() string {
762 return a.gitState.Slug()
763}
764
765// IncrementRetryNumber increments the retry number for branch naming conflicts
766func (a *Agent) IncrementRetryNumber() {
767 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000768}
769
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000770// OutstandingLLMCallCount returns the number of outstanding LLM calls.
771func (a *Agent) OutstandingLLMCallCount() int {
772 a.mu.Lock()
773 defer a.mu.Unlock()
774 return len(a.outstandingLLMCalls)
775}
776
777// OutstandingToolCalls returns the names of outstanding tool calls.
778func (a *Agent) OutstandingToolCalls() []string {
779 a.mu.Lock()
780 defer a.mu.Unlock()
781
782 tools := make([]string, 0, len(a.outstandingToolCalls))
783 for _, toolName := range a.outstandingToolCalls {
784 tools = append(tools, toolName)
785 }
786 return tools
787}
788
Earl Lee2e463fb2025-04-17 11:22:22 -0700789// OS returns the operating system of the client.
790func (a *Agent) OS() string {
791 return a.config.ClientGOOS
792}
793
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000794func (a *Agent) SessionID() string {
795 return a.config.SessionID
796}
797
philip.zeyliger8773e682025-06-11 21:36:21 -0700798// SSHConnectionString returns the SSH connection string for the container.
799func (a *Agent) SSHConnectionString() string {
800 return a.config.SSHConnectionString
801}
802
Philip Zeyliger18532b22025-04-23 21:11:46 +0000803// OutsideOS returns the operating system of the outside system.
804func (a *Agent) OutsideOS() string {
805 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000806}
807
Philip Zeyliger18532b22025-04-23 21:11:46 +0000808// OutsideHostname returns the hostname of the outside system.
809func (a *Agent) OutsideHostname() string {
810 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000811}
812
Philip Zeyliger18532b22025-04-23 21:11:46 +0000813// OutsideWorkingDir returns the working directory on the outside system.
814func (a *Agent) OutsideWorkingDir() string {
815 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000816}
817
818// GitOrigin returns the URL of the git remote 'origin' if it exists.
819func (a *Agent) GitOrigin() string {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000820 return a.config.OriginalGitOrigin
Philip Zeyligerd1402952025-04-23 03:54:37 +0000821}
822
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700823// PassthroughUpstream returns whether passthrough upstream is enabled.
824func (a *Agent) PassthroughUpstream() bool {
825 return a.config.PassthroughUpstream
826}
827
bankseancad67b02025-06-27 21:57:05 +0000828// GitUsername returns the git user name from the agent config.
829func (a *Agent) GitUsername() string {
830 return a.config.GitUsername
831}
832
Philip Zeyliger64f60462025-06-16 13:57:10 -0700833// DiffStats returns the number of lines added and removed from sketch-base to HEAD
834func (a *Agent) DiffStats() (int, int) {
835 return a.gitState.DiffStats()
836}
837
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000838func (a *Agent) OpenBrowser(url string) {
839 if !a.IsInContainer() {
840 browser.Open(url)
841 return
842 }
843 // We're in Docker, need to send a request to the Git server
844 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700845 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000846 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700847 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000848 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700849 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000850 return
851 }
852 defer resp.Body.Close()
853 if resp.StatusCode == http.StatusOK {
854 return
855 }
856 body, _ := io.ReadAll(resp.Body)
857 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
858}
859
Sean McCullough96b60dd2025-04-30 09:49:10 -0700860// CurrentState returns the current state of the agent's state machine.
861func (a *Agent) CurrentState() State {
862 return a.stateMachine.CurrentState()
863}
864
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700865func (a *Agent) IsInContainer() bool {
866 return a.config.InDocker
867}
868
869func (a *Agent) FirstMessageIndex() int {
870 a.mu.Lock()
871 defer a.mu.Unlock()
872 return a.firstMessageIndex
873}
874
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700875// SetSlug sets a human-readable identifier for the conversation.
876func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700877 a.mu.Lock()
878 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700879
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700880 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000881 convo, ok := a.convo.(*conversation.Convo)
882 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700883 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000884 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700885}
886
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000887// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700888func (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 +0000889 // Track the tool call
890 a.mu.Lock()
891 a.outstandingToolCalls[id] = toolName
892 a.mu.Unlock()
893}
894
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700895// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
896// If there's only one element in the array and it's a text type, it returns that text directly.
897// It also processes nested ToolResult arrays recursively.
898func contentToString(contents []llm.Content) string {
899 if len(contents) == 0 {
900 return ""
901 }
902
903 // If there's only one element and it's a text type, return it directly
904 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
905 return contents[0].Text
906 }
907
908 // Otherwise, concatenate all text content
909 var result strings.Builder
910 for _, content := range contents {
911 if content.Type == llm.ContentTypeText {
912 result.WriteString(content.Text)
913 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
914 // Recursively process nested tool results
915 result.WriteString(contentToString(content.ToolResult))
916 }
917 }
918
919 return result.String()
920}
921
Earl Lee2e463fb2025-04-17 11:22:22 -0700922// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700923func (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 +0000924 // Remove the tool call from outstanding calls
925 a.mu.Lock()
926 delete(a.outstandingToolCalls, toolID)
927 a.mu.Unlock()
928
Earl Lee2e463fb2025-04-17 11:22:22 -0700929 m := AgentMessage{
930 Type: ToolUseMessageType,
931 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700932 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700933 ToolError: content.ToolError,
934 ToolName: toolName,
935 ToolInput: string(toolInput),
936 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700937 StartTime: content.ToolUseStartTime,
938 EndTime: content.ToolUseEndTime,
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700939 Display: content.Display,
Earl Lee2e463fb2025-04-17 11:22:22 -0700940 }
941
942 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700943 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
944 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700945 m.Elapsed = &elapsed
946 }
947
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700948 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700949 a.pushToOutbox(ctx, m)
950}
951
952// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700953func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000954 a.mu.Lock()
955 defer a.mu.Unlock()
956 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700957 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
958}
959
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700960// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700961// that need to be displayed (as well as tool calls that we send along when
962// they're done). (It would be reasonable to also mention tool calls when they're
963// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700964func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000965 // Remove the LLM call from outstanding calls
966 a.mu.Lock()
967 delete(a.outstandingLLMCalls, id)
968 a.mu.Unlock()
969
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700970 if resp == nil {
971 // LLM API call failed
972 m := AgentMessage{
973 Type: ErrorMessageType,
974 Content: "API call failed, type 'continue' to try again",
975 }
976 m.SetConvo(convo)
977 a.pushToOutbox(ctx, m)
978 return
979 }
980
Earl Lee2e463fb2025-04-17 11:22:22 -0700981 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700982 if convo.Parent == nil { // subconvos never end the turn
983 switch resp.StopReason {
984 case llm.StopReasonToolUse:
985 // Check whether any of the tool calls are for tools that should end the turn
986 ToolSearch:
987 for _, part := range resp.Content {
988 if part.Type != llm.ContentTypeToolUse {
989 continue
990 }
Sean McCullough021557a2025-05-05 23:20:53 +0000991 // Find the tool by name
992 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700993 if tool.Name == part.ToolName {
994 endOfTurn = tool.EndsTurn
995 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000996 }
997 }
Sean McCullough021557a2025-05-05 23:20:53 +0000998 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700999 default:
1000 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +00001001 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001002 }
1003 m := AgentMessage{
1004 Type: AgentMessageType,
1005 Content: collectTextContent(resp),
1006 EndOfTurn: endOfTurn,
1007 Usage: &resp.Usage,
1008 StartTime: resp.StartTime,
1009 EndTime: resp.EndTime,
1010 }
1011
1012 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001013 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -07001014 var toolCalls []ToolCall
1015 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001016 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -07001017 toolCalls = append(toolCalls, ToolCall{
1018 Name: part.ToolName,
1019 Input: string(part.ToolInput),
1020 ToolCallId: part.ID,
1021 })
1022 }
1023 }
1024 m.ToolCalls = toolCalls
1025 }
1026
1027 // Calculate the elapsed time if both start and end times are set
1028 if resp.StartTime != nil && resp.EndTime != nil {
1029 elapsed := resp.EndTime.Sub(*resp.StartTime)
1030 m.Elapsed = &elapsed
1031 }
1032
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -07001033 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -07001034 a.pushToOutbox(ctx, m)
1035}
1036
1037// WorkingDir implements CodingAgent.
1038func (a *Agent) WorkingDir() string {
1039 return a.workingDir
1040}
1041
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001042// RepoRoot returns the git repository root directory.
1043func (a *Agent) RepoRoot() string {
1044 return a.repoRoot
1045}
1046
Earl Lee2e463fb2025-04-17 11:22:22 -07001047// MessageCount implements CodingAgent.
1048func (a *Agent) MessageCount() int {
1049 a.mu.Lock()
1050 defer a.mu.Unlock()
1051 return len(a.history)
1052}
1053
1054// Messages implements CodingAgent.
1055func (a *Agent) Messages(start int, end int) []AgentMessage {
1056 a.mu.Lock()
1057 defer a.mu.Unlock()
1058 return slices.Clone(a.history[start:end])
1059}
1060
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001061// ShouldCompact checks if the conversation should be compacted based on token usage
1062func (a *Agent) ShouldCompact() bool {
1063 // Get the threshold from environment variable, default to 0.94 (94%)
1064 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
1065 // and a little bit of buffer.)
1066 thresholdRatio := 0.94
1067 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
1068 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
1069 thresholdRatio = parsed
1070 }
1071 }
1072
1073 // Get the most recent usage to check current context size
1074 lastUsage := a.convo.LastUsage()
1075
1076 if lastUsage.InputTokens == 0 {
1077 // No API calls made yet
1078 return false
1079 }
1080
1081 // Calculate the current context size from the last API call
1082 // This includes all tokens that were part of the input context:
1083 // - Input tokens (user messages, system prompt, conversation history)
1084 // - Cache read tokens (cached parts of the context)
1085 // - Cache creation tokens (new parts being cached)
1086 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1087
1088 // Get the service's token context window
1089 service := a.config.Service
1090 contextWindow := service.TokenContextWindow()
1091
1092 // Calculate threshold
1093 threshold := uint64(float64(contextWindow) * thresholdRatio)
1094
1095 // Check if we've exceeded the threshold
1096 return currentContextSize >= threshold
1097}
1098
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001099func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001100 return a.originalBudget
1101}
1102
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001103// Upstream returns the upstream branch for git work
1104func (a *Agent) Upstream() string {
1105 return a.gitState.Upstream()
1106}
1107
Earl Lee2e463fb2025-04-17 11:22:22 -07001108// AgentConfig contains configuration for creating a new Agent.
1109type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001110 Context context.Context
1111 Service llm.Service
1112 Budget conversation.Budget
1113 GitUsername string
1114 GitEmail string
1115 SessionID string
1116 ClientGOOS string
1117 ClientGOARCH string
1118 InDocker bool
1119 OneShot bool
1120 WorkingDir string
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +00001121 // Model is the name of the LLM model being used
1122 Model string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001123 // Outside information
1124 OutsideHostname string
1125 OutsideOS string
1126 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001127
1128 // Outtie's HTTP to, e.g., open a browser
1129 OutsideHTTP string
1130 // Outtie's Git server
1131 GitRemoteAddr string
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001132 // Original git origin URL from host repository, if any
1133 OriginalGitOrigin string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001134 // Upstream branch for git work
1135 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001136 // Commit to checkout from Outtie
1137 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001138 // Prefix for git branches created by sketch
1139 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001140 // LinkToGitHub enables GitHub branch linking in UI
1141 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001142 // SSH connection string for connecting to the container
1143 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001144 // Skaband client for session history (optional)
1145 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001146 // MCP server configurations
1147 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001148 // Timeout configuration for bash tool
1149 BashTimeouts *claudetool.Timeouts
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001150 // PassthroughUpstream configures upstream remote for passthrough to innie
1151 PassthroughUpstream bool
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001152 // FetchOnLaunch enables git fetch during initialization
1153 FetchOnLaunch bool
Earl Lee2e463fb2025-04-17 11:22:22 -07001154}
1155
1156// NewAgent creates a new Agent.
1157// It is not usable until Init() is called.
1158func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001159 // Set default branch prefix if not specified
1160 if config.BranchPrefix == "" {
1161 config.BranchPrefix = "sketch/"
1162 }
1163
Earl Lee2e463fb2025-04-17 11:22:22 -07001164 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001165 config: config,
1166 ready: make(chan struct{}),
1167 inbox: make(chan string, 100),
1168 subscribers: make([]chan *AgentMessage, 0),
1169 startedAt: time.Now(),
1170 originalBudget: config.Budget,
1171 gitState: AgentGitState{
1172 seenCommits: make(map[string]bool),
1173 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001174 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001175 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001176 outsideHostname: config.OutsideHostname,
1177 outsideOS: config.OutsideOS,
1178 outsideWorkingDir: config.OutsideWorkingDir,
1179 outstandingLLMCalls: make(map[string]struct{}),
1180 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001181 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001182 workingDir: config.WorkingDir,
1183 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001184
1185 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001186 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001187
1188 // Initialize port monitor with 5-second interval
1189 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1190
Earl Lee2e463fb2025-04-17 11:22:22 -07001191 return agent
1192}
1193
1194type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001195 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001196
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001197 InDocker bool
1198 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001199}
1200
1201func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001202 if a.convo != nil {
1203 return fmt.Errorf("Agent.Init: already initialized")
1204 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001205 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001206 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001207
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001208 // If a remote + commit was specified, clone it.
1209 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001210 if _, err := os.Stat("/app/.git"); err != nil {
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00001211 slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
1212 // TODO: --reference-if-able instead?
1213 cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
1214 if out, err := cmd.CombinedOutput(); err != nil {
1215 return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err)
1216 }
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001217 }
1218 }
1219
1220 if a.workingDir != "" {
1221 err := os.Chdir(a.workingDir)
1222 if err != nil {
1223 return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err)
1224 }
1225 }
1226
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001227 if !ini.NoGit {
Philip Zeyligeraccf37c2025-07-18 07:29:19 -07001228 if a.gitState.gitRemoteAddr != "" {
1229 if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil {
1230 return err
1231 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001232 }
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001233
1234 // Configure git user settings
1235 if a.config.GitEmail != "" {
1236 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1237 cmd.Dir = a.workingDir
1238 if out, err := cmd.CombinedOutput(); err != nil {
1239 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1240 }
1241 }
1242 if a.config.GitUsername != "" {
1243 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1244 cmd.Dir = a.workingDir
1245 if out, err := cmd.CombinedOutput(); err != nil {
1246 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1247 }
1248 }
1249 // Configure git http.postBuffer
1250 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1251 cmd.Dir = a.workingDir
1252 if out, err := cmd.CombinedOutput(); err != nil {
1253 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1254 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001255
1256 // Configure passthrough upstream if enabled
1257 if a.config.PassthroughUpstream {
1258 if err := a.configurePassthroughUpstream(ctx); err != nil {
1259 return fmt.Errorf("failed to configure passthrough upstream: %w", err)
1260 }
1261 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001262 }
1263
Philip Zeyligerf2872992025-05-22 10:35:28 -07001264 // If a commit was specified, we fetch and reset to it.
1265 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001266 if a.config.FetchOnLaunch {
1267 slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit)
1268 cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
1269 cmd.Dir = a.workingDir
1270 if out, err := cmd.CombinedOutput(); err != nil {
1271 return fmt.Errorf("git fetch: %s: %w", out, err)
1272 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001273 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001274 // The -B resets the branch if it already exists (or creates it if it doesn't)
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001275 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001276 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001277 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1278 // Remove git hooks if they exist and retry
1279 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001280 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001281 if _, statErr := os.Stat(hookPath); statErr == nil {
1282 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1283 slog.String("error", err.Error()),
1284 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001285 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001286 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1287 }
1288
1289 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001290 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001291 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001292 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001293 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 +01001294 }
1295 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001296 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001297 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001298 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001299 } else if a.IsInContainer() {
1300 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1301 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1302 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1303 cmd.Dir = a.workingDir
1304 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1305 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1306 }
1307 } else {
1308 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001309 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001310
1311 if ini.HostAddr != "" {
1312 a.url = "http://" + ini.HostAddr
1313 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001314
1315 if !ini.NoGit {
1316 repoRoot, err := repoRoot(ctx, a.workingDir)
1317 if err != nil {
1318 return fmt.Errorf("repoRoot: %w", err)
1319 }
1320 a.repoRoot = repoRoot
1321
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001322 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001323 if err := setupGitHooks(a.repoRoot); err != nil {
1324 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1325 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001326 }
1327
philz24613202025-07-15 20:56:21 -07001328 // Check if we have any commits, and if not, create an empty initial commit
1329 cmd := exec.CommandContext(ctx, "git", "rev-list", "--all", "--count")
1330 cmd.Dir = repoRoot
1331 countOut, err := cmd.CombinedOutput()
1332 if err != nil {
1333 return fmt.Errorf("git rev-list --all --count: %s: %w", countOut, err)
1334 }
1335 commitCount := strings.TrimSpace(string(countOut))
1336 if commitCount == "0" {
1337 slog.Info("No commits found, creating empty initial commit")
1338 cmd = exec.CommandContext(ctx, "git", "commit", "--allow-empty", "-m", "Initial empty commit")
1339 cmd.Dir = repoRoot
1340 if commitOut, err := cmd.CombinedOutput(); err != nil {
1341 return fmt.Errorf("git commit --allow-empty: %s: %w", commitOut, err)
1342 }
1343 }
1344
1345 cmd = exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
Philip Zeyliger49edc922025-05-14 09:45:45 -07001346 cmd.Dir = repoRoot
1347 if out, err := cmd.CombinedOutput(); err != nil {
1348 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1349 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001350
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001351 slog.Info("running codebase analysis")
1352 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1353 if err != nil {
1354 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001355 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001356 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001357
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001358 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001359 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001360 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001361 }
1362 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001363
Earl Lee2e463fb2025-04-17 11:22:22 -07001364 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001365 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001366 a.convo = a.initConvo()
1367 close(a.ready)
1368 return nil
1369}
1370
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001371//go:embed agent_system_prompt.txt
1372var agentSystemPrompt string
1373
Earl Lee2e463fb2025-04-17 11:22:22 -07001374// initConvo initializes the conversation.
1375// It must not be called until all agent fields are initialized,
1376// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001377func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001378 return a.initConvoWithUsage(nil)
1379}
1380
1381// initConvoWithUsage initializes the conversation with optional preserved usage.
1382func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001383 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001384 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001385 convo.PromptCaching = true
1386 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001387 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001388 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001389
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001390 bashTool := &claudetool.BashTool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001391 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1392 Timeouts: a.config.BashTimeouts,
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -07001393 Pwd: a.workingDir,
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001394 }
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001395 patchTool := &claudetool.PatchTool{
Josh Bleecher Snyder7f18fb62025-07-30 18:12:29 -07001396 Callback: a.patchCallback,
1397 Pwd: a.workingDir,
Josh Bleecher Snyder994e9842025-07-30 20:26:47 -07001398 Simplified: llm.UseSimplifiedPatch(a.config.Service),
Josh Bleecher Snyder7f18fb62025-07-30 18:12:29 -07001399 ClipboardEnabled: experiment.Enabled("clipboard"),
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001400 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001401
Earl Lee2e463fb2025-04-17 11:22:22 -07001402 // Register all tools with the conversation
1403 // When adding, removing, or modifying tools here, double-check that the termui tool display
1404 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001405
1406 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001407 _, supportsScreenshots := a.config.Service.(*ant.Service)
1408 var bTools []*llm.Tool
1409 var browserCleanup func()
1410
1411 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1412 // Add cleanup function to context cancel
1413 go func() {
1414 <-a.config.Context.Done()
1415 browserCleanup()
1416 }()
1417 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001418
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001419 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001420 bashTool.Tool(),
1421 claudetool.Keyword,
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001422 patchTool.Tool(),
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001423 claudetool.Think,
1424 claudetool.TodoRead,
1425 claudetool.TodoWrite,
1426 makeDoneTool(a.codereview),
1427 a.codereview.Tool(),
1428 claudetool.AboutSketch,
gio30503072025-06-17 10:50:15 +00001429 dodo_tools.NewGetProjectConfigTool(),
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001430 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001431 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001432
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001433 // Add MCP tools if configured
1434 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001435
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001436 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001437 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1438
1439 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1440 for i := range serverConfigs {
1441 if serverConfigs[i].Headers != nil {
1442 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001443 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1444 if strings.HasPrefix(value, "env:") {
1445 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001446 }
1447 }
1448 }
1449 }
Philip Zeyligerc540df72025-07-25 09:21:56 -07001450 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, mcp.DefaultMCPConnectionTimeout, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001451
1452 if len(mcpErrors) > 0 {
1453 for _, err := range mcpErrors {
1454 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1455 // Send agent message about MCP connection failures
1456 a.pushToOutbox(ctx, AgentMessage{
1457 Type: ErrorMessageType,
1458 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1459 })
1460 }
1461 }
1462
1463 if len(mcpConnections) > 0 {
1464 // Add tools from all successful connections
1465 totalTools := 0
1466 for _, connection := range mcpConnections {
1467 convo.Tools = append(convo.Tools, connection.Tools...)
1468 totalTools += len(connection.Tools)
1469 // Log tools per server using structured data
1470 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1471 }
1472 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1473 } else {
1474 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1475 }
1476 }
1477
Earl Lee2e463fb2025-04-17 11:22:22 -07001478 convo.Listener = a
1479 return convo
1480}
1481
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001482// branchExists reports whether branchName exists, either locally or in well-known remotes.
1483func branchExists(dir, branchName string) bool {
1484 refs := []string{
1485 "refs/heads/",
1486 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001487 }
1488 for _, ref := range refs {
1489 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1490 cmd.Dir = dir
1491 if cmd.Run() == nil { // exit code 0 means branch exists
1492 return true
1493 }
1494 }
1495 return false
1496}
1497
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001498func soleText(contents []llm.Content) (string, error) {
1499 if len(contents) != 1 {
1500 return "", fmt.Errorf("multiple contents %v", contents)
1501 }
1502 content := contents[0]
1503 if content.Type != llm.ContentTypeText || content.Text == "" {
1504 return "", fmt.Errorf("bad content %v", content)
1505 }
1506 return strings.TrimSpace(content.Text), nil
1507}
1508
1509// autoGenerateSlug automatically generates a slug based on the first user input
1510func (a *Agent) autoGenerateSlug(ctx context.Context, userContents []llm.Content) error {
1511 userText, err := soleText(userContents)
1512 if err != nil {
1513 return err
1514 }
1515 if userText == "" {
1516 return fmt.Errorf("set-slug: empty text content")
1517 }
1518
1519 // Create a subconversation without history for slug generation
1520 convo, ok := a.convo.(*conversation.Convo)
1521 if !ok {
1522 // In test environments, the conversation might be a mock interface
1523 // Skip slug generation in this case
1524 return fmt.Errorf("set-slug: can't make a subconvo (mock convo?)")
1525 }
1526
1527 // Loop until we find an acceptable slug
1528 var unavailableSlugs []string
1529 for {
1530 if len(unavailableSlugs) > 10 {
1531 // sanity check to prevent infinite loops
1532 return fmt.Errorf("set-slug: failed to construct a new slug after %d attempts", len(unavailableSlugs))
Earl Lee2e463fb2025-04-17 11:22:22 -07001533 }
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001534 subConvo := convo.SubConvo()
1535 subConvo.Hidden = true
1536
1537 // Prompt for slug generation
1538 prompt := `You are a slug generator for Sketch, an agentic coding environment.
1539The user's prompt will be in <user-prompt> tags. Any unavailable slugs will be listed in <unavailable-slug> tags.
1540Generate a 2-3 word alphanumeric hyphenated slug in imperative tense that captures the essence of their coding task.
1541Respond with only the slug.`
1542
1543 buf := new(strings.Builder)
1544 buf.WriteString("<slug-request>")
1545 if len(unavailableSlugs) > 0 {
1546 buf.WriteString("<unavailable-slugs>")
1547 }
1548 for _, x := range unavailableSlugs {
1549 buf.WriteString("<unavailable-slug>")
1550 buf.WriteString(x)
1551 buf.WriteString("</unavailable-slug>")
1552 }
1553 if len(unavailableSlugs) > 0 {
1554 buf.WriteString("</unavailable-slugs>")
1555 }
1556 buf.WriteString("<user-prompt>")
1557 buf.WriteString(userText)
1558 buf.WriteString("</user-prompt>")
1559 buf.WriteString("</slug-request>")
1560
1561 fullPrompt := prompt + "\n" + buf.String()
1562 userMessage := llm.UserStringMessage(fullPrompt)
1563
1564 resp, err := subConvo.SendMessage(userMessage)
1565 if err != nil {
1566 return fmt.Errorf("failed to generate slug: %w", err)
1567 }
1568
1569 // Extract the slug from the response
1570 slugText, err := soleText(resp.Content)
1571 if err != nil {
1572 return err
1573 }
1574 if slugText == "" {
1575 return fmt.Errorf("empty slug generated")
1576 }
1577
1578 // Clean and validate the slug
1579 slug := cleanSlugName(slugText)
1580 if slug == "" {
1581 return fmt.Errorf("slug could not be cleaned: %q", slugText)
1582 }
1583
1584 // Check if branch already exists using the same logic as the original set-slug tool
1585 a.SetSlug(slug) // Set slug first so BranchName() works correctly
1586 if branchExists(a.workingDir, a.BranchName()) {
1587 // try again
1588 unavailableSlugs = append(unavailableSlugs, slug)
1589 continue
1590 }
1591
1592 // Success! Slug is available and already set
1593 return nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001594 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001595}
1596
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001597// patchCallback is the agent's patch tool callback.
1598// It warms the codereview cache in the background.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001599func (a *Agent) patchCallback(input claudetool.PatchInput, output llm.ToolOut) llm.ToolOut {
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001600 if a.codereview != nil {
1601 a.codereview.WarmTestCache(input.Path)
1602 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001603 return output
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001604}
1605
Earl Lee2e463fb2025-04-17 11:22:22 -07001606func (a *Agent) Ready() <-chan struct{} {
1607 return a.ready
1608}
1609
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001610// BranchPrefix returns the configured branch prefix
1611func (a *Agent) BranchPrefix() string {
1612 return a.config.BranchPrefix
1613}
1614
philip.zeyliger6d3de482025-06-10 19:38:14 -07001615// LinkToGitHub returns whether GitHub branch linking is enabled
1616func (a *Agent) LinkToGitHub() bool {
1617 return a.config.LinkToGitHub
1618}
1619
Earl Lee2e463fb2025-04-17 11:22:22 -07001620func (a *Agent) UserMessage(ctx context.Context, msg string) {
1621 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1622 a.inbox <- msg
1623}
1624
Earl Lee2e463fb2025-04-17 11:22:22 -07001625func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1626 return a.convo.CancelToolUse(toolUseID, cause)
1627}
1628
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001629func (a *Agent) CancelTurn(cause error) {
1630 a.cancelTurnMu.Lock()
1631 defer a.cancelTurnMu.Unlock()
1632 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001633 // Force state transition to cancelled state
1634 ctx := a.config.Context
1635 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001636 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001637 }
1638}
1639
1640func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001641 // Start port monitoring
1642 if a.portMonitor != nil && a.IsInContainer() {
1643 if err := a.portMonitor.Start(ctxOuter); err != nil {
1644 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1645 } else {
1646 slog.InfoContext(ctxOuter, "Port monitor started")
1647 }
1648 }
1649
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001650 // Set up cleanup when context is done
1651 defer func() {
1652 if a.mcpManager != nil {
1653 a.mcpManager.Close()
1654 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001655 if a.portMonitor != nil && a.IsInContainer() {
1656 a.portMonitor.Stop()
1657 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001658 }()
1659
Earl Lee2e463fb2025-04-17 11:22:22 -07001660 for {
1661 select {
1662 case <-ctxOuter.Done():
1663 return
1664 default:
1665 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001666 a.cancelTurnMu.Lock()
1667 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001668 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001669 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001670 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001671 a.cancelTurn = cancel
1672 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001673 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1674 if err != nil {
1675 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1676 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001677 cancel(nil)
1678 }
1679 }
1680}
1681
1682func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1683 if m.Timestamp.IsZero() {
1684 m.Timestamp = time.Now()
1685 }
1686
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001687 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1688 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1689 m.Content = m.ToolResult
1690 }
1691
Earl Lee2e463fb2025-04-17 11:22:22 -07001692 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1693 if m.EndOfTurn && m.Type == AgentMessageType {
1694 turnDuration := time.Since(a.startOfTurn)
1695 m.TurnDuration = &turnDuration
1696 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1697 }
1698
Earl Lee2e463fb2025-04-17 11:22:22 -07001699 a.mu.Lock()
1700 defer a.mu.Unlock()
1701 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001702 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001703 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001704
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001705 // Notify all subscribers
1706 for _, ch := range a.subscribers {
1707 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001708 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001709}
1710
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001711func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1712 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001713 if block {
1714 select {
1715 case <-ctx.Done():
1716 return m, ctx.Err()
1717 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001718 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001719 }
1720 }
1721 for {
1722 select {
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 default:
1726 return m, nil
1727 }
1728 }
1729}
1730
Sean McCullough885a16a2025-04-30 02:49:25 +00001731// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001732func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001733 // Reset the start of turn time
1734 a.startOfTurn = time.Now()
1735
Sean McCullough96b60dd2025-04-30 09:49:10 -07001736 // Transition to waiting for user input state
1737 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1738
Sean McCullough885a16a2025-04-30 02:49:25 +00001739 // Process initial user message
1740 initialResp, err := a.processUserMessage(ctx)
1741 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001742 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001743 return err
1744 }
1745
1746 // Handle edge case where both initialResp and err are nil
1747 if initialResp == nil {
1748 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001749 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1750
Sean McCullough9f4b8082025-04-30 17:34:07 +00001751 a.pushToOutbox(ctx, errorMessage(err))
1752 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001753 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001754
Earl Lee2e463fb2025-04-17 11:22:22 -07001755 // We do this as we go, but let's also do it at the end of the turn
1756 defer func() {
1757 if _, err := a.handleGitCommits(ctx); err != nil {
1758 // Just log the error, don't stop execution
1759 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1760 }
1761 }()
1762
Sean McCullougha1e0e492025-05-01 10:51:08 -07001763 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001764 resp := initialResp
1765 for {
1766 // Check if we are over budget
1767 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001768 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001769 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001770 }
1771
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001772 // Check if we should compact the conversation
1773 if a.ShouldCompact() {
1774 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1775 if err := a.CompactConversation(ctx); err != nil {
1776 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1777 return err
1778 }
1779 // After compaction, end this turn and start fresh
1780 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1781 return nil
1782 }
1783
Sean McCullough885a16a2025-04-30 02:49:25 +00001784 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001785 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001786 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001787 break
1788 }
1789
Sean McCullough96b60dd2025-04-30 09:49:10 -07001790 // Transition to tool use requested state
1791 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1792
Sean McCullough885a16a2025-04-30 02:49:25 +00001793 // Handle tool execution
1794 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1795 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001796 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001797 }
1798
Sean McCullougha1e0e492025-05-01 10:51:08 -07001799 if toolResp == nil {
1800 return fmt.Errorf("cannot continue conversation with a nil tool response")
1801 }
1802
Sean McCullough885a16a2025-04-30 02:49:25 +00001803 // Set the response for the next iteration
1804 resp = toolResp
1805 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001806
1807 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001808}
1809
1810// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001811func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001812 // Wait for at least one message from the user
1813 msgs, err := a.GatherMessages(ctx, true)
1814 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001815 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001816 return nil, err
1817 }
1818
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001819 // Auto-generate slug if this is the first user input and no slug is set
1820 if a.Slug() == "" {
1821 if err := a.autoGenerateSlug(ctx, msgs); err != nil {
1822 // NB: it is possible that autoGenerateSlug set the slug during the process
1823 // of trying to generate a slug.
1824 // The fact that it returned an error means that we cannot use that slug.
1825 slog.WarnContext(ctx, "Failed to auto-generate slug", "error", err)
1826 // use the session id instead. ugly, but we need a slug, and this will be unique.
1827 a.SetSlug(a.SessionID())
1828 }
1829 // Notify termui of the final slug (only emitted once, after slug is determined)
1830 a.pushToOutbox(ctx, AgentMessage{
1831 Type: SlugMessageType,
1832 Content: a.Slug(),
1833 })
1834 }
1835
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001836 userMessage := llm.Message{
1837 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001838 Content: msgs,
1839 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001840
Sean McCullough96b60dd2025-04-30 09:49:10 -07001841 // Transition to sending to LLM state
1842 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1843
Sean McCullough885a16a2025-04-30 02:49:25 +00001844 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001845 resp, err := a.convo.SendMessage(userMessage)
1846 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001847 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001848 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001849 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001850 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001851
Sean McCullough96b60dd2025-04-30 09:49:10 -07001852 // Transition to processing LLM response state
1853 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1854
Sean McCullough885a16a2025-04-30 02:49:25 +00001855 return resp, nil
1856}
1857
1858// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001859func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1860 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001861 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001862 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001863
Sean McCullough96b60dd2025-04-30 09:49:10 -07001864 // Transition to checking for cancellation state
1865 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1866
Sean McCullough885a16a2025-04-30 02:49:25 +00001867 // Check if the operation was cancelled by the user
1868 select {
1869 case <-ctx.Done():
1870 // Don't actually run any of the tools, but rather build a response
1871 // for each tool_use message letting the LLM know that user canceled it.
1872 var err error
1873 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001874 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001875 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001876 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001877 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001878 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001879 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001880 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001881 // Transition to running tool state
1882 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1883
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001884 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001885 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001886 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001887
1888 // Execute the tools
1889 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001890 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001891 if ctx.Err() != nil { // e.g. the user canceled the operation
1892 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001893 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001894 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001895 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001896 a.pushToOutbox(ctx, errorMessage(err))
1897 }
1898 }
1899
1900 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001901 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001902 autoqualityMessages := a.processGitChanges(ctx)
1903
1904 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001905 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001906 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001907 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001908 return false, nil
1909 }
1910
1911 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001912 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1913 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001914}
1915
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001916// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001917func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001918 // Check for git commits
1919 _, err := a.handleGitCommits(ctx)
1920 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001921 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001922 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001923 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001924 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001925}
1926
1927// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1928// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001929func (a *Agent) processGitChanges(ctx context.Context) []string {
1930 // Check for git commits after tool execution
1931 newCommits, err := a.handleGitCommits(ctx)
1932 if err != nil {
1933 // Just log the error, don't stop execution
1934 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1935 return nil
1936 }
1937
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001938 // Run mechanical checks if there was exactly one new commit.
1939 if len(newCommits) != 1 {
1940 return nil
1941 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001942 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001943 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1944 msg := a.codereview.RunMechanicalChecks(ctx)
1945 if msg != "" {
1946 a.pushToOutbox(ctx, AgentMessage{
1947 Type: AutoMessageType,
1948 Content: msg,
1949 Timestamp: time.Now(),
1950 })
1951 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001952 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001953
1954 return autoqualityMessages
1955}
1956
1957// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001958func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001959 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001960 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001961 msgs, err := a.GatherMessages(ctx, false)
1962 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001963 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001964 return false, nil
1965 }
1966
1967 // Inject any auto-generated messages from quality checks
1968 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001969 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001970 }
1971
1972 // Handle cancellation by appending a message about it
1973 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001974 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001975 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001976 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001977 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1978 } else if err := a.convo.OverBudget(); err != nil {
1979 // Handle budget issues by appending a message about it
1980 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 -07001981 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001982 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1983 }
1984
1985 // Combine tool results with user messages
1986 results = append(results, msgs...)
1987
1988 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001989 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001990 resp, err := a.convo.SendMessage(llm.Message{
1991 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001992 Content: results,
1993 })
1994 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001995 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001996 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1997 return true, nil // Return true to continue the conversation, but with no response
1998 }
1999
Sean McCullough96b60dd2025-04-30 09:49:10 -07002000 // Transition back to processing LLM response
2001 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
2002
Sean McCullough885a16a2025-04-30 02:49:25 +00002003 if cancelled {
2004 return false, nil
2005 }
2006
2007 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07002008}
2009
2010func (a *Agent) overBudget(ctx context.Context) error {
2011 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07002012 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07002013 m := budgetMessage(err)
2014 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07002015 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07002016 a.convo.ResetBudget(a.originalBudget)
2017 return err
2018 }
2019 return nil
2020}
2021
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002022func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07002023 // Collect all text content
2024 var allText strings.Builder
2025 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002026 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002027 if allText.Len() > 0 {
2028 allText.WriteString("\n\n")
2029 }
2030 allText.WriteString(content.Text)
2031 }
2032 }
2033 return allText.String()
2034}
2035
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002036func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07002037 a.mu.Lock()
2038 defer a.mu.Unlock()
2039 return a.convo.CumulativeUsage()
2040}
2041
Earl Lee2e463fb2025-04-17 11:22:22 -07002042// Diff returns a unified diff of changes made since the agent was instantiated.
2043func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07002044 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002045 return "", fmt.Errorf("no initial commit reference available")
2046 }
2047
2048 // Find the repository root
2049 ctx := context.Background()
2050
2051 // If a specific commit hash is provided, show just that commit's changes
2052 if commit != nil && *commit != "" {
2053 // Validate that the commit looks like a valid git SHA
2054 if !isValidGitSHA(*commit) {
2055 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
2056 }
2057
2058 // Get the diff for just this commit
2059 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
2060 cmd.Dir = a.repoRoot
2061 output, err := cmd.CombinedOutput()
2062 if err != nil {
2063 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
2064 }
2065 return string(output), nil
2066 }
2067
2068 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07002069 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07002070 cmd.Dir = a.repoRoot
2071 output, err := cmd.CombinedOutput()
2072 if err != nil {
2073 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
2074 }
2075
2076 return string(output), nil
2077}
2078
Philip Zeyliger49edc922025-05-14 09:45:45 -07002079// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
2080// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
2081func (a *Agent) SketchGitBaseRef() string {
2082 if a.IsInContainer() {
2083 return "sketch-base"
2084 } else {
2085 return "sketch-base-" + a.SessionID()
2086 }
2087}
2088
2089// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
2090func (a *Agent) SketchGitBase() string {
2091 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
2092 cmd.Dir = a.repoRoot
2093 output, err := cmd.CombinedOutput()
2094 if err != nil {
2095 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
2096 return "HEAD"
2097 }
2098 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002099}
2100
Pokey Rule7a113622025-05-12 10:58:45 +01002101// removeGitHooks removes the Git hooks directory from the repository
2102func removeGitHooks(_ context.Context, repoPath string) error {
2103 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2104
2105 // Check if hooks directory exists
2106 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2107 // Directory doesn't exist, nothing to do
2108 return nil
2109 }
2110
2111 // Remove the hooks directory
2112 err := os.RemoveAll(hooksDir)
2113 if err != nil {
2114 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2115 }
2116
2117 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002118 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002119 if err != nil {
2120 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2121 }
2122
2123 return nil
2124}
2125
Philip Zeyligerf2872992025-05-22 10:35:28 -07002126func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002127 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002128 for _, msg := range msgs {
2129 a.pushToOutbox(ctx, msg)
2130 }
2131 return commits, error
2132}
2133
Earl Lee2e463fb2025-04-17 11:22:22 -07002134// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002135// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002136func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002137 ags.mu.Lock()
2138 defer ags.mu.Unlock()
2139
2140 msgs := []AgentMessage{}
2141 if repoRoot == "" {
2142 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002143 }
2144
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002145 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002146 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002147 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002148 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002149 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002150 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002151 }
2152 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002153 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002154 }()
2155
Philip Zeyliger64f60462025-06-16 13:57:10 -07002156 // Compute diff stats from baseRef to HEAD when HEAD changes
2157 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2158 // Log error but don't fail the entire operation
2159 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2160 } else {
2161 // Set diff stats directly since we already hold the mutex
2162 ags.linesAdded = added
2163 ags.linesRemoved = removed
2164 }
2165
Earl Lee2e463fb2025-04-17 11:22:22 -07002166 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2167 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2168 // to the last 100 commits.
2169 var commits []*GitCommit
2170
2171 // Get commits since the initial commit
2172 // Format: <hash>\0<subject>\0<body>\0
2173 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2174 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002175 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 -07002176 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002177 output, err := cmd.Output()
2178 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002179 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002180 }
2181
2182 // Parse git log output and filter out already seen commits
2183 parsedCommits := parseGitLog(string(output))
2184
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002185 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002186
2187 // Filter out commits we've already seen
2188 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002189 if commit.Hash == sketch {
2190 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002191 }
2192
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002193 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2194 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002195 continue
2196 }
2197
2198 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002199 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002200
2201 // Add to our list of new commits
2202 commits = append(commits, &commit)
2203 }
2204
Philip Zeyligerf2872992025-05-22 10:35:28 -07002205 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002206 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002207 // 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 -07002208 sketchCommit = &GitCommit{}
2209 sketchCommit.Hash = sketch
2210 sketchCommit.Subject = "unknown"
2211 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002212 }
2213
Earl Lee2e463fb2025-04-17 11:22:22 -07002214 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2215 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2216 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002217
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002218 // 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 +00002219 var out []byte
2220 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002221 originalRetryNumber := ags.retryNumber
2222 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002223 for retries := range 10 {
2224 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002225 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002226 }
2227
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002228 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002229 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002230 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002231 out, err = cmd.CombinedOutput()
2232
2233 if err == nil {
2234 // Success! Break out of the retry loop
2235 break
2236 }
2237
2238 // Check if this is the "refusing to update checked out branch" error
2239 if !strings.Contains(string(out), "refusing to update checked out branch") {
2240 // This is a different error, so don't retry
2241 break
2242 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002243 }
2244
2245 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002246 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002247 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002248 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002249 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002250 if ags.retryNumber != originalRetryNumber {
2251 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002252 msgs = append(msgs, AgentMessage{
2253 Type: AutoMessageType,
2254 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002255 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 +00002256 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002257 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002258 }
2259 }
2260
2261 // If we found new commits, create a message
2262 if len(commits) > 0 {
2263 msg := AgentMessage{
2264 Type: CommitMessageType,
2265 Timestamp: time.Now(),
2266 Commits: commits,
2267 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002268 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002269 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002270 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002271}
2272
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002273func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002274 return strings.Map(func(r rune) rune {
2275 // lowercase
2276 if r >= 'A' && r <= 'Z' {
2277 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002278 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002279 // replace spaces with dashes
2280 if r == ' ' {
2281 return '-'
2282 }
2283 // allow alphanumerics and dashes
2284 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2285 return r
2286 }
2287 return -1
2288 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002289}
2290
2291// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2292// and returns an array of GitCommit structs.
2293func parseGitLog(output string) []GitCommit {
2294 var commits []GitCommit
2295
2296 // No output means no commits
2297 if len(output) == 0 {
2298 return commits
2299 }
2300
2301 // Split by NULL byte
2302 parts := strings.Split(output, "\x00")
2303
2304 // Process in triplets (hash, subject, body)
2305 for i := 0; i < len(parts); i++ {
2306 // Skip empty parts
2307 if parts[i] == "" {
2308 continue
2309 }
2310
2311 // This should be a hash
2312 hash := strings.TrimSpace(parts[i])
2313
2314 // Make sure we have at least a subject part available
2315 if i+1 >= len(parts) {
2316 break // No more parts available
2317 }
2318
2319 // Get the subject
2320 subject := strings.TrimSpace(parts[i+1])
2321
2322 // Get the body if available
2323 body := ""
2324 if i+2 < len(parts) {
2325 body = strings.TrimSpace(parts[i+2])
2326 }
2327
2328 // Skip to the next triplet
2329 i += 2
2330
2331 commits = append(commits, GitCommit{
2332 Hash: hash,
2333 Subject: subject,
2334 Body: body,
2335 })
2336 }
2337
2338 return commits
2339}
2340
2341func repoRoot(ctx context.Context, dir string) (string, error) {
2342 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2343 stderr := new(strings.Builder)
2344 cmd.Stderr = stderr
2345 cmd.Dir = dir
2346 out, err := cmd.Output()
2347 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002348 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002349 }
2350 return strings.TrimSpace(string(out)), nil
2351}
2352
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002353// upsertRemoteOrigin configures the origin remote to point to the given URL.
2354// If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002355//
2356// NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL.
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002357func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
2358 // Try to set the URL for existing origin remote
2359 cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
2360 cmd.Dir = repoDir
2361 if _, err := cmd.CombinedOutput(); err == nil {
2362 // Success.
2363 return nil
2364 }
2365 // Origin doesn't exist; add it.
2366 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL)
2367 cmd.Dir = repoDir
2368 if out, err := cmd.CombinedOutput(); err != nil {
2369 return fmt.Errorf("failed to add git remote origin: %s: %w", out, err)
2370 }
2371 return nil
2372}
2373
Earl Lee2e463fb2025-04-17 11:22:22 -07002374func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2375 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2376 stderr := new(strings.Builder)
2377 cmd.Stderr = stderr
2378 cmd.Dir = dir
2379 out, err := cmd.Output()
2380 if err != nil {
2381 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2382 }
2383 // TODO: validate that out is valid hex
2384 return strings.TrimSpace(string(out)), nil
2385}
2386
2387// isValidGitSHA validates if a string looks like a valid git SHA hash.
2388// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2389func isValidGitSHA(sha string) bool {
2390 // Git SHA must be a hexadecimal string with at least 4 characters
2391 if len(sha) < 4 || len(sha) > 40 {
2392 return false
2393 }
2394
2395 // Check if the string only contains hexadecimal characters
2396 for _, char := range sha {
2397 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2398 return false
2399 }
2400 }
2401
2402 return true
2403}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002404
Philip Zeyliger64f60462025-06-16 13:57:10 -07002405// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2406func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2407 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2408 cmd.Dir = repoRoot
2409 out, err := cmd.Output()
2410 if err != nil {
2411 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2412 }
2413
2414 var totalAdded, totalRemoved int
2415 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2416 for _, line := range lines {
2417 if line == "" {
2418 continue
2419 }
2420 parts := strings.Fields(line)
2421 if len(parts) < 2 {
2422 continue
2423 }
2424 // Format: <added>\t<removed>\t<filename>
2425 if added, err := strconv.Atoi(parts[0]); err == nil {
2426 totalAdded += added
2427 }
2428 if removed, err := strconv.Atoi(parts[1]); err == nil {
2429 totalRemoved += removed
2430 }
2431 }
2432
2433 return totalAdded, totalRemoved, nil
2434}
2435
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002436// systemPromptData contains the data used to render the system prompt template
2437type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002438 ClientGOOS string
2439 ClientGOARCH string
2440 WorkingDir string
2441 RepoRoot string
2442 InitialCommit string
2443 Codebase *onstart.Codebase
2444 UseSketchWIP bool
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002445 InstallationNudge bool
David Crawshawc886ac52025-06-13 23:40:03 +00002446 Branch string
2447 SpecialInstruction string
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002448 Now string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002449}
2450
2451// renderSystemPrompt renders the system prompt template.
2452func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002453 nowFn := a.now
2454 if nowFn == nil {
2455 nowFn = time.Now
2456 }
2457 now := nowFn()
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002458 data := systemPromptData{
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002459 ClientGOOS: a.config.ClientGOOS,
2460 ClientGOARCH: a.config.ClientGOARCH,
2461 WorkingDir: a.workingDir,
2462 RepoRoot: a.repoRoot,
2463 InitialCommit: a.SketchGitBase(),
2464 Codebase: a.codebase,
2465 UseSketchWIP: a.config.InDocker,
2466 InstallationNudge: a.config.InDocker,
Josh Bleecher Snyder9224eb02025-07-26 04:45:05 +00002467 Now: now.Format(time.DateOnly),
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002468 }
David Crawshawc886ac52025-06-13 23:40:03 +00002469 if now.Month() == time.September && now.Day() == 19 {
Josh Bleecher Snyder783ab312025-07-25 07:22:38 -07002470 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 +00002471 }
2472
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002473 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2474 if err != nil {
2475 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2476 }
2477 buf := new(strings.Builder)
2478 err = tmpl.Execute(buf, data)
2479 if err != nil {
2480 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2481 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002482 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002483 return buf.String()
2484}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002485
2486// StateTransitionIterator provides an iterator over state transitions.
2487type StateTransitionIterator interface {
2488 // Next blocks until a new state transition is available or context is done.
2489 // Returns nil if the context is cancelled.
2490 Next() *StateTransition
2491 // Close removes the listener and cleans up resources.
2492 Close()
2493}
2494
2495// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2496type StateTransitionIteratorImpl struct {
2497 agent *Agent
2498 ctx context.Context
2499 ch chan StateTransition
2500 unsubscribe func()
2501}
2502
2503// Next blocks until a new state transition is available or the context is cancelled.
2504func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2505 select {
2506 case <-s.ctx.Done():
2507 return nil
2508 case transition, ok := <-s.ch:
2509 if !ok {
2510 return nil
2511 }
2512 transitionCopy := transition
2513 return &transitionCopy
2514 }
2515}
2516
2517// Close removes the listener and cleans up resources.
2518func (s *StateTransitionIteratorImpl) Close() {
2519 if s.unsubscribe != nil {
2520 s.unsubscribe()
2521 s.unsubscribe = nil
2522 }
2523}
2524
2525// NewStateTransitionIterator returns an iterator that receives state transitions.
2526func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2527 a.mu.Lock()
2528 defer a.mu.Unlock()
2529
2530 // Create channel to receive state transitions
2531 ch := make(chan StateTransition, 10)
2532
2533 // Add a listener to the state machine
2534 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2535
2536 return &StateTransitionIteratorImpl{
2537 agent: a,
2538 ctx: ctx,
2539 ch: ch,
2540 unsubscribe: unsubscribe,
2541 }
2542}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002543
2544// setupGitHooks creates or updates git hooks in the specified working directory.
2545func setupGitHooks(workingDir string) error {
2546 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2547
2548 _, err := os.Stat(hooksDir)
2549 if os.IsNotExist(err) {
2550 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2551 }
2552 if err != nil {
2553 return fmt.Errorf("error checking git hooks directory: %w", err)
2554 }
2555
2556 // Define the post-commit hook content
2557 postCommitHook := `#!/bin/bash
2558echo "<post_commit_hook>"
2559echo "Please review this commit message and fix it if it is incorrect."
2560echo "This hook only echos the commit message; it does not modify it."
2561echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2562echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002563PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002564echo "</last_commit_message>"
2565echo "</post_commit_hook>"
2566`
2567
2568 // Define the prepare-commit-msg hook content
2569 prepareCommitMsgHook := `#!/bin/bash
2570# Add Co-Authored-By and Change-ID trailers to commit messages
2571# Check if these trailers already exist before adding them
2572
2573commit_file="$1"
2574COMMIT_SOURCE="$2"
2575
2576# Skip for merges, squashes, or when using a commit template
2577if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2578 [ "$COMMIT_SOURCE" = "squash" ]; then
2579 exit 0
2580fi
2581
2582commit_msg=$(cat "$commit_file")
2583
2584needs_co_author=true
2585needs_change_id=true
2586
2587# Check if commit message already has Co-Authored-By trailer
2588if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2589 needs_co_author=false
2590fi
2591
2592# Check if commit message already has Change-ID trailer
2593if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2594 needs_change_id=false
2595fi
2596
2597# Only modify if at least one trailer needs to be added
2598if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002599 # Ensure there's a proper blank line before trailers
2600 if [ -s "$commit_file" ]; then
2601 # Check if file ends with newline by reading last character
2602 last_char=$(tail -c 1 "$commit_file")
2603
2604 if [ "$last_char" != "" ]; then
2605 # File doesn't end with newline - add two newlines (complete line + blank line)
2606 echo "" >> "$commit_file"
2607 echo "" >> "$commit_file"
2608 else
2609 # File ends with newline - check if we already have a blank line
2610 last_line=$(tail -1 "$commit_file")
2611 if [ -n "$last_line" ]; then
2612 # Last line has content - add one newline for blank line
2613 echo "" >> "$commit_file"
2614 fi
2615 # If last line is empty, we already have a blank line - don't add anything
2616 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002617 fi
2618
2619 # Add trailers if needed
2620 if [ "$needs_co_author" = true ]; then
2621 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2622 fi
2623
2624 if [ "$needs_change_id" = true ]; then
2625 change_id=$(openssl rand -hex 8)
2626 echo "Change-ID: s${change_id}k" >> "$commit_file"
2627 fi
2628fi
2629`
2630
2631 // Update or create the post-commit hook
2632 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2633 if err != nil {
2634 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2635 }
2636
2637 // Update or create the prepare-commit-msg hook
2638 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2639 if err != nil {
2640 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2641 }
2642
2643 return nil
2644}
2645
2646// updateOrCreateHook creates a new hook file or updates an existing one
2647// by appending the new content if it doesn't already contain it.
2648func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2649 // Check if the hook already exists
2650 buf, err := os.ReadFile(hookPath)
2651 if os.IsNotExist(err) {
2652 // Hook doesn't exist, create it
2653 err = os.WriteFile(hookPath, []byte(content), 0o755)
2654 if err != nil {
2655 return fmt.Errorf("failed to create hook: %w", err)
2656 }
2657 return nil
2658 }
2659 if err != nil {
2660 return fmt.Errorf("error reading existing hook: %w", err)
2661 }
2662
2663 // Hook exists, check if our content is already in it by looking for a distinctive line
2664 code := string(buf)
2665 if strings.Contains(code, distinctiveLine) {
2666 // Already contains our content, nothing to do
2667 return nil
2668 }
2669
2670 // Append our content to the existing hook
2671 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2672 if err != nil {
2673 return fmt.Errorf("failed to open hook for appending: %w", err)
2674 }
2675 defer f.Close()
2676
2677 // Ensure there's a newline at the end of the existing content if needed
2678 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2679 _, err = f.WriteString("\n")
2680 if err != nil {
2681 return fmt.Errorf("failed to add newline to hook: %w", err)
2682 }
2683 }
2684
2685 // Add a separator before our content
2686 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2687 if err != nil {
2688 return fmt.Errorf("failed to append to hook: %w", err)
2689 }
2690
2691 return nil
2692}
Sean McCullough138ec242025-06-02 22:42:06 +00002693
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002694// configurePassthroughUpstream configures git remotes
2695// Adds an upstream remote pointing to the same as origin
2696// Sets the refspec for upstream and fetch such that both
2697// fetch the upstream's things into refs/remotes/upstream/foo
2698// The typical scenario is:
2699//
2700// github - laptop - sketch container
2701// "upstream" "origin"
2702func (a *Agent) configurePassthroughUpstream(ctx context.Context) error {
2703 // Get the origin remote URL
2704 cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
2705 cmd.Dir = a.workingDir
2706 originURLBytes, err := cmd.CombinedOutput()
2707 if err != nil {
2708 return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err)
2709 }
2710 originURL := strings.TrimSpace(string(originURLBytes))
2711
2712 // Check if upstream remote already exists
2713 cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream")
2714 cmd.Dir = a.workingDir
2715 if _, err := cmd.CombinedOutput(); err != nil {
2716 // upstream remote doesn't exist, create it
2717 cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL)
2718 cmd.Dir = a.workingDir
2719 if out, err := cmd.CombinedOutput(); err != nil {
2720 return fmt.Errorf("failed to add upstream remote: %s: %w", out, err)
2721 }
2722 slog.InfoContext(ctx, "added upstream remote", "url", originURL)
2723 } else {
2724 // upstream remote exists, update its URL
2725 cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL)
2726 cmd.Dir = a.workingDir
2727 if out, err := cmd.CombinedOutput(); err != nil {
2728 return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err)
2729 }
2730 slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL)
2731 }
2732
2733 // Add the upstream refspec to the upstream remote
2734 cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2735 cmd.Dir = a.workingDir
2736 if out, err := cmd.CombinedOutput(); err != nil {
2737 return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err)
2738 }
2739
2740 // Add the same refspec to the origin remote
2741 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2742 cmd.Dir = a.workingDir
2743 if out, err := cmd.CombinedOutput(); err != nil {
2744 return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err)
2745 }
2746
2747 slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL)
2748 return nil
2749}
2750
Philip Zeyliger0113be52025-06-07 23:53:41 +00002751// SkabandAddr returns the skaband address if configured
2752func (a *Agent) SkabandAddr() string {
2753 if a.config.SkabandClient != nil {
2754 return a.config.SkabandClient.Addr()
2755 }
2756 return ""
2757}
bankseanbdc68892025-07-28 17:28:13 -07002758
2759// ExternalMsg represents a message from a source external to the agent/user conversation,
2760// such as the outcome of a github workflow run.
2761type ExternalMessage struct {
2762 MessageType string `json:"message_type"`
2763 Body any `json:"body"`
2764 TextContent string `json:"text_content"`
2765}