blob: 4ce4fa3410169ca1322f4c3bbb46ebab2ad35809 [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
giofe6e7142025-06-18 08:51:23 +00001419 // TODO(gio): get these from the config
1420 dodoTools := dodo_tools.NewDodoTools(os.Getenv("DODO_API_BASE_ADDR"), os.Getenv("DODO_PROJECT_ID"))
1421
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001422 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001423 bashTool.Tool(),
1424 claudetool.Keyword,
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001425 patchTool.Tool(),
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001426 claudetool.Think,
1427 claudetool.TodoRead,
1428 claudetool.TodoWrite,
1429 makeDoneTool(a.codereview),
1430 a.codereview.Tool(),
1431 claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001432 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001433 convo.Tools = append(convo.Tools, browserTools...)
giofe6e7142025-06-18 08:51:23 +00001434 convo.Tools = append(convo.Tools, dodoTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001435
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001436 // Add MCP tools if configured
1437 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001438
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001439 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001440 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1441
1442 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1443 for i := range serverConfigs {
1444 if serverConfigs[i].Headers != nil {
1445 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001446 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1447 if strings.HasPrefix(value, "env:") {
1448 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001449 }
1450 }
1451 }
1452 }
Philip Zeyligerc540df72025-07-25 09:21:56 -07001453 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, mcp.DefaultMCPConnectionTimeout, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001454
1455 if len(mcpErrors) > 0 {
1456 for _, err := range mcpErrors {
1457 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1458 // Send agent message about MCP connection failures
1459 a.pushToOutbox(ctx, AgentMessage{
1460 Type: ErrorMessageType,
1461 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1462 })
1463 }
1464 }
1465
1466 if len(mcpConnections) > 0 {
1467 // Add tools from all successful connections
1468 totalTools := 0
1469 for _, connection := range mcpConnections {
1470 convo.Tools = append(convo.Tools, connection.Tools...)
1471 totalTools += len(connection.Tools)
1472 // Log tools per server using structured data
1473 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1474 }
1475 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1476 } else {
1477 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1478 }
1479 }
1480
Earl Lee2e463fb2025-04-17 11:22:22 -07001481 convo.Listener = a
1482 return convo
1483}
1484
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001485// branchExists reports whether branchName exists, either locally or in well-known remotes.
1486func branchExists(dir, branchName string) bool {
1487 refs := []string{
1488 "refs/heads/",
1489 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001490 }
1491 for _, ref := range refs {
1492 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1493 cmd.Dir = dir
1494 if cmd.Run() == nil { // exit code 0 means branch exists
1495 return true
1496 }
1497 }
1498 return false
1499}
1500
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001501func soleText(contents []llm.Content) (string, error) {
1502 if len(contents) != 1 {
1503 return "", fmt.Errorf("multiple contents %v", contents)
1504 }
1505 content := contents[0]
1506 if content.Type != llm.ContentTypeText || content.Text == "" {
1507 return "", fmt.Errorf("bad content %v", content)
1508 }
1509 return strings.TrimSpace(content.Text), nil
1510}
1511
1512// autoGenerateSlug automatically generates a slug based on the first user input
1513func (a *Agent) autoGenerateSlug(ctx context.Context, userContents []llm.Content) error {
1514 userText, err := soleText(userContents)
1515 if err != nil {
1516 return err
1517 }
1518 if userText == "" {
1519 return fmt.Errorf("set-slug: empty text content")
1520 }
1521
1522 // Create a subconversation without history for slug generation
1523 convo, ok := a.convo.(*conversation.Convo)
1524 if !ok {
1525 // In test environments, the conversation might be a mock interface
1526 // Skip slug generation in this case
1527 return fmt.Errorf("set-slug: can't make a subconvo (mock convo?)")
1528 }
1529
1530 // Loop until we find an acceptable slug
1531 var unavailableSlugs []string
1532 for {
1533 if len(unavailableSlugs) > 10 {
1534 // sanity check to prevent infinite loops
1535 return fmt.Errorf("set-slug: failed to construct a new slug after %d attempts", len(unavailableSlugs))
Earl Lee2e463fb2025-04-17 11:22:22 -07001536 }
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001537 subConvo := convo.SubConvo()
1538 subConvo.Hidden = true
1539
1540 // Prompt for slug generation
1541 prompt := `You are a slug generator for Sketch, an agentic coding environment.
1542The user's prompt will be in <user-prompt> tags. Any unavailable slugs will be listed in <unavailable-slug> tags.
1543Generate a 2-3 word alphanumeric hyphenated slug in imperative tense that captures the essence of their coding task.
1544Respond with only the slug.`
1545
1546 buf := new(strings.Builder)
1547 buf.WriteString("<slug-request>")
1548 if len(unavailableSlugs) > 0 {
1549 buf.WriteString("<unavailable-slugs>")
1550 }
1551 for _, x := range unavailableSlugs {
1552 buf.WriteString("<unavailable-slug>")
1553 buf.WriteString(x)
1554 buf.WriteString("</unavailable-slug>")
1555 }
1556 if len(unavailableSlugs) > 0 {
1557 buf.WriteString("</unavailable-slugs>")
1558 }
1559 buf.WriteString("<user-prompt>")
1560 buf.WriteString(userText)
1561 buf.WriteString("</user-prompt>")
1562 buf.WriteString("</slug-request>")
1563
1564 fullPrompt := prompt + "\n" + buf.String()
1565 userMessage := llm.UserStringMessage(fullPrompt)
1566
1567 resp, err := subConvo.SendMessage(userMessage)
1568 if err != nil {
1569 return fmt.Errorf("failed to generate slug: %w", err)
1570 }
1571
1572 // Extract the slug from the response
1573 slugText, err := soleText(resp.Content)
1574 if err != nil {
1575 return err
1576 }
1577 if slugText == "" {
1578 return fmt.Errorf("empty slug generated")
1579 }
1580
1581 // Clean and validate the slug
1582 slug := cleanSlugName(slugText)
1583 if slug == "" {
1584 return fmt.Errorf("slug could not be cleaned: %q", slugText)
1585 }
1586
1587 // Check if branch already exists using the same logic as the original set-slug tool
1588 a.SetSlug(slug) // Set slug first so BranchName() works correctly
1589 if branchExists(a.workingDir, a.BranchName()) {
1590 // try again
1591 unavailableSlugs = append(unavailableSlugs, slug)
1592 continue
1593 }
1594
1595 // Success! Slug is available and already set
1596 return nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001597 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001598}
1599
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001600// patchCallback is the agent's patch tool callback.
1601// It warms the codereview cache in the background.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001602func (a *Agent) patchCallback(input claudetool.PatchInput, output llm.ToolOut) llm.ToolOut {
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001603 if a.codereview != nil {
1604 a.codereview.WarmTestCache(input.Path)
1605 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001606 return output
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001607}
1608
Earl Lee2e463fb2025-04-17 11:22:22 -07001609func (a *Agent) Ready() <-chan struct{} {
1610 return a.ready
1611}
1612
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001613// BranchPrefix returns the configured branch prefix
1614func (a *Agent) BranchPrefix() string {
1615 return a.config.BranchPrefix
1616}
1617
philip.zeyliger6d3de482025-06-10 19:38:14 -07001618// LinkToGitHub returns whether GitHub branch linking is enabled
1619func (a *Agent) LinkToGitHub() bool {
1620 return a.config.LinkToGitHub
1621}
1622
Earl Lee2e463fb2025-04-17 11:22:22 -07001623func (a *Agent) UserMessage(ctx context.Context, msg string) {
1624 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1625 a.inbox <- msg
1626}
1627
Earl Lee2e463fb2025-04-17 11:22:22 -07001628func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1629 return a.convo.CancelToolUse(toolUseID, cause)
1630}
1631
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001632func (a *Agent) CancelTurn(cause error) {
1633 a.cancelTurnMu.Lock()
1634 defer a.cancelTurnMu.Unlock()
1635 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001636 // Force state transition to cancelled state
1637 ctx := a.config.Context
1638 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001639 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001640 }
1641}
1642
1643func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001644 // Start port monitoring
1645 if a.portMonitor != nil && a.IsInContainer() {
1646 if err := a.portMonitor.Start(ctxOuter); err != nil {
1647 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1648 } else {
1649 slog.InfoContext(ctxOuter, "Port monitor started")
1650 }
1651 }
1652
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001653 // Set up cleanup when context is done
1654 defer func() {
1655 if a.mcpManager != nil {
1656 a.mcpManager.Close()
1657 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001658 if a.portMonitor != nil && a.IsInContainer() {
1659 a.portMonitor.Stop()
1660 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001661 }()
1662
Earl Lee2e463fb2025-04-17 11:22:22 -07001663 for {
1664 select {
1665 case <-ctxOuter.Done():
1666 return
1667 default:
1668 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001669 a.cancelTurnMu.Lock()
1670 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001671 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001672 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001673 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001674 a.cancelTurn = cancel
1675 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001676 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1677 if err != nil {
1678 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1679 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001680 cancel(nil)
1681 }
1682 }
1683}
1684
1685func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1686 if m.Timestamp.IsZero() {
1687 m.Timestamp = time.Now()
1688 }
1689
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001690 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1691 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1692 m.Content = m.ToolResult
1693 }
1694
Earl Lee2e463fb2025-04-17 11:22:22 -07001695 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1696 if m.EndOfTurn && m.Type == AgentMessageType {
1697 turnDuration := time.Since(a.startOfTurn)
1698 m.TurnDuration = &turnDuration
1699 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1700 }
1701
Earl Lee2e463fb2025-04-17 11:22:22 -07001702 a.mu.Lock()
1703 defer a.mu.Unlock()
1704 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001705 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001706 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001707
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001708 // Notify all subscribers
1709 for _, ch := range a.subscribers {
1710 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001711 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001712}
1713
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001714func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1715 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001716 if block {
1717 select {
1718 case <-ctx.Done():
1719 return m, ctx.Err()
1720 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001721 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001722 }
1723 }
1724 for {
1725 select {
1726 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001727 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001728 default:
1729 return m, nil
1730 }
1731 }
1732}
1733
Sean McCullough885a16a2025-04-30 02:49:25 +00001734// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001735func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001736 // Reset the start of turn time
1737 a.startOfTurn = time.Now()
1738
Sean McCullough96b60dd2025-04-30 09:49:10 -07001739 // Transition to waiting for user input state
1740 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1741
Sean McCullough885a16a2025-04-30 02:49:25 +00001742 // Process initial user message
1743 initialResp, err := a.processUserMessage(ctx)
1744 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001745 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001746 return err
1747 }
1748
1749 // Handle edge case where both initialResp and err are nil
1750 if initialResp == nil {
1751 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001752 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1753
Sean McCullough9f4b8082025-04-30 17:34:07 +00001754 a.pushToOutbox(ctx, errorMessage(err))
1755 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001756 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001757
Earl Lee2e463fb2025-04-17 11:22:22 -07001758 // We do this as we go, but let's also do it at the end of the turn
1759 defer func() {
1760 if _, err := a.handleGitCommits(ctx); err != nil {
1761 // Just log the error, don't stop execution
1762 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1763 }
1764 }()
1765
Sean McCullougha1e0e492025-05-01 10:51:08 -07001766 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001767 resp := initialResp
1768 for {
1769 // Check if we are over budget
1770 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001771 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001772 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001773 }
1774
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001775 // Check if we should compact the conversation
1776 if a.ShouldCompact() {
1777 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1778 if err := a.CompactConversation(ctx); err != nil {
1779 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1780 return err
1781 }
1782 // After compaction, end this turn and start fresh
1783 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1784 return nil
1785 }
1786
Sean McCullough885a16a2025-04-30 02:49:25 +00001787 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001788 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001789 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001790 break
1791 }
1792
Sean McCullough96b60dd2025-04-30 09:49:10 -07001793 // Transition to tool use requested state
1794 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1795
Sean McCullough885a16a2025-04-30 02:49:25 +00001796 // Handle tool execution
1797 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1798 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001799 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001800 }
1801
Sean McCullougha1e0e492025-05-01 10:51:08 -07001802 if toolResp == nil {
1803 return fmt.Errorf("cannot continue conversation with a nil tool response")
1804 }
1805
Sean McCullough885a16a2025-04-30 02:49:25 +00001806 // Set the response for the next iteration
1807 resp = toolResp
1808 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001809
1810 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001811}
1812
1813// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001814func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001815 // Wait for at least one message from the user
1816 msgs, err := a.GatherMessages(ctx, true)
1817 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001818 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001819 return nil, err
1820 }
1821
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001822 // Auto-generate slug if this is the first user input and no slug is set
1823 if a.Slug() == "" {
1824 if err := a.autoGenerateSlug(ctx, msgs); err != nil {
1825 // NB: it is possible that autoGenerateSlug set the slug during the process
1826 // of trying to generate a slug.
1827 // The fact that it returned an error means that we cannot use that slug.
1828 slog.WarnContext(ctx, "Failed to auto-generate slug", "error", err)
1829 // use the session id instead. ugly, but we need a slug, and this will be unique.
1830 a.SetSlug(a.SessionID())
1831 }
1832 // Notify termui of the final slug (only emitted once, after slug is determined)
1833 a.pushToOutbox(ctx, AgentMessage{
1834 Type: SlugMessageType,
1835 Content: a.Slug(),
1836 })
1837 }
1838
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001839 userMessage := llm.Message{
1840 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001841 Content: msgs,
1842 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001843
Sean McCullough96b60dd2025-04-30 09:49:10 -07001844 // Transition to sending to LLM state
1845 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1846
Sean McCullough885a16a2025-04-30 02:49:25 +00001847 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001848 resp, err := a.convo.SendMessage(userMessage)
1849 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001850 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001851 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001852 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001853 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001854
Sean McCullough96b60dd2025-04-30 09:49:10 -07001855 // Transition to processing LLM response state
1856 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1857
Sean McCullough885a16a2025-04-30 02:49:25 +00001858 return resp, nil
1859}
1860
1861// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001862func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1863 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001864 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001865 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001866
Sean McCullough96b60dd2025-04-30 09:49:10 -07001867 // Transition to checking for cancellation state
1868 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1869
Sean McCullough885a16a2025-04-30 02:49:25 +00001870 // Check if the operation was cancelled by the user
1871 select {
1872 case <-ctx.Done():
1873 // Don't actually run any of the tools, but rather build a response
1874 // for each tool_use message letting the LLM know that user canceled it.
1875 var err error
1876 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001877 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001878 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001879 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001880 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001881 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001882 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001883 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001884 // Transition to running tool state
1885 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1886
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001887 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001888 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001889 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001890
1891 // Execute the tools
1892 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001893 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001894 if ctx.Err() != nil { // e.g. the user canceled the operation
1895 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001896 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001897 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001898 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001899 a.pushToOutbox(ctx, errorMessage(err))
1900 }
1901 }
1902
1903 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001904 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001905 autoqualityMessages := a.processGitChanges(ctx)
1906
1907 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001908 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001909 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001910 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001911 return false, nil
1912 }
1913
1914 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001915 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1916 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001917}
1918
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001919// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001920func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001921 // Check for git commits
1922 _, err := a.handleGitCommits(ctx)
1923 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001924 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001925 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001926 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001927 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001928}
1929
1930// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1931// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001932func (a *Agent) processGitChanges(ctx context.Context) []string {
1933 // Check for git commits after tool execution
1934 newCommits, err := a.handleGitCommits(ctx)
1935 if err != nil {
1936 // Just log the error, don't stop execution
1937 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1938 return nil
1939 }
1940
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001941 // Run mechanical checks if there was exactly one new commit.
1942 if len(newCommits) != 1 {
1943 return nil
1944 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001945 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001946 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1947 msg := a.codereview.RunMechanicalChecks(ctx)
1948 if msg != "" {
1949 a.pushToOutbox(ctx, AgentMessage{
1950 Type: AutoMessageType,
1951 Content: msg,
1952 Timestamp: time.Now(),
1953 })
1954 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001955 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001956
1957 return autoqualityMessages
1958}
1959
1960// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001961func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001962 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001963 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001964 msgs, err := a.GatherMessages(ctx, false)
1965 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001966 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001967 return false, nil
1968 }
1969
1970 // Inject any auto-generated messages from quality checks
1971 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001972 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001973 }
1974
1975 // Handle cancellation by appending a message about it
1976 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001977 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001978 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001979 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001980 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1981 } else if err := a.convo.OverBudget(); err != nil {
1982 // Handle budget issues by appending a message about it
1983 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 -07001984 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001985 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1986 }
1987
1988 // Combine tool results with user messages
1989 results = append(results, msgs...)
1990
1991 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001992 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001993 resp, err := a.convo.SendMessage(llm.Message{
1994 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001995 Content: results,
1996 })
1997 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001998 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001999 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
2000 return true, nil // Return true to continue the conversation, but with no response
2001 }
2002
Sean McCullough96b60dd2025-04-30 09:49:10 -07002003 // Transition back to processing LLM response
2004 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
2005
Sean McCullough885a16a2025-04-30 02:49:25 +00002006 if cancelled {
2007 return false, nil
2008 }
2009
2010 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07002011}
2012
2013func (a *Agent) overBudget(ctx context.Context) error {
2014 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07002015 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07002016 m := budgetMessage(err)
2017 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07002018 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07002019 a.convo.ResetBudget(a.originalBudget)
2020 return err
2021 }
2022 return nil
2023}
2024
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002025func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07002026 // Collect all text content
2027 var allText strings.Builder
2028 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002029 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002030 if allText.Len() > 0 {
2031 allText.WriteString("\n\n")
2032 }
2033 allText.WriteString(content.Text)
2034 }
2035 }
2036 return allText.String()
2037}
2038
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002039func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07002040 a.mu.Lock()
2041 defer a.mu.Unlock()
2042 return a.convo.CumulativeUsage()
2043}
2044
Earl Lee2e463fb2025-04-17 11:22:22 -07002045// Diff returns a unified diff of changes made since the agent was instantiated.
2046func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07002047 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002048 return "", fmt.Errorf("no initial commit reference available")
2049 }
2050
2051 // Find the repository root
2052 ctx := context.Background()
2053
2054 // If a specific commit hash is provided, show just that commit's changes
2055 if commit != nil && *commit != "" {
2056 // Validate that the commit looks like a valid git SHA
2057 if !isValidGitSHA(*commit) {
2058 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
2059 }
2060
2061 // Get the diff for just this commit
2062 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
2063 cmd.Dir = a.repoRoot
2064 output, err := cmd.CombinedOutput()
2065 if err != nil {
2066 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
2067 }
2068 return string(output), nil
2069 }
2070
2071 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07002072 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07002073 cmd.Dir = a.repoRoot
2074 output, err := cmd.CombinedOutput()
2075 if err != nil {
2076 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
2077 }
2078
2079 return string(output), nil
2080}
2081
Philip Zeyliger49edc922025-05-14 09:45:45 -07002082// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
2083// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
2084func (a *Agent) SketchGitBaseRef() string {
2085 if a.IsInContainer() {
2086 return "sketch-base"
2087 } else {
2088 return "sketch-base-" + a.SessionID()
2089 }
2090}
2091
2092// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
2093func (a *Agent) SketchGitBase() string {
2094 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
2095 cmd.Dir = a.repoRoot
2096 output, err := cmd.CombinedOutput()
2097 if err != nil {
2098 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
2099 return "HEAD"
2100 }
2101 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002102}
2103
Pokey Rule7a113622025-05-12 10:58:45 +01002104// removeGitHooks removes the Git hooks directory from the repository
2105func removeGitHooks(_ context.Context, repoPath string) error {
2106 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2107
2108 // Check if hooks directory exists
2109 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2110 // Directory doesn't exist, nothing to do
2111 return nil
2112 }
2113
2114 // Remove the hooks directory
2115 err := os.RemoveAll(hooksDir)
2116 if err != nil {
2117 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2118 }
2119
2120 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002121 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002122 if err != nil {
2123 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2124 }
2125
2126 return nil
2127}
2128
Philip Zeyligerf2872992025-05-22 10:35:28 -07002129func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002130 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002131 for _, msg := range msgs {
2132 a.pushToOutbox(ctx, msg)
2133 }
2134 return commits, error
2135}
2136
Earl Lee2e463fb2025-04-17 11:22:22 -07002137// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002138// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002139func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002140 ags.mu.Lock()
2141 defer ags.mu.Unlock()
2142
2143 msgs := []AgentMessage{}
2144 if repoRoot == "" {
2145 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002146 }
2147
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002148 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002149 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002150 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002151 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002152 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002153 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002154 }
2155 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002156 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002157 }()
2158
Philip Zeyliger64f60462025-06-16 13:57:10 -07002159 // Compute diff stats from baseRef to HEAD when HEAD changes
2160 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2161 // Log error but don't fail the entire operation
2162 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2163 } else {
2164 // Set diff stats directly since we already hold the mutex
2165 ags.linesAdded = added
2166 ags.linesRemoved = removed
2167 }
2168
Earl Lee2e463fb2025-04-17 11:22:22 -07002169 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2170 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2171 // to the last 100 commits.
2172 var commits []*GitCommit
2173
2174 // Get commits since the initial commit
2175 // Format: <hash>\0<subject>\0<body>\0
2176 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2177 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002178 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 -07002179 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002180 output, err := cmd.Output()
2181 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002182 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002183 }
2184
2185 // Parse git log output and filter out already seen commits
2186 parsedCommits := parseGitLog(string(output))
2187
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002188 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002189
2190 // Filter out commits we've already seen
2191 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002192 if commit.Hash == sketch {
2193 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002194 }
2195
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002196 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2197 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002198 continue
2199 }
2200
2201 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002202 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002203
2204 // Add to our list of new commits
2205 commits = append(commits, &commit)
2206 }
2207
Philip Zeyligerf2872992025-05-22 10:35:28 -07002208 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002209 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002210 // 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 -07002211 sketchCommit = &GitCommit{}
2212 sketchCommit.Hash = sketch
2213 sketchCommit.Subject = "unknown"
2214 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002215 }
2216
Earl Lee2e463fb2025-04-17 11:22:22 -07002217 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2218 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2219 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002220
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002221 // 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 +00002222 var out []byte
2223 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002224 originalRetryNumber := ags.retryNumber
2225 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002226 for retries := range 10 {
2227 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002228 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002229 }
2230
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002231 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002232 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002233 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002234 out, err = cmd.CombinedOutput()
2235
2236 if err == nil {
2237 // Success! Break out of the retry loop
2238 break
2239 }
2240
2241 // Check if this is the "refusing to update checked out branch" error
2242 if !strings.Contains(string(out), "refusing to update checked out branch") {
2243 // This is a different error, so don't retry
2244 break
2245 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002246 }
2247
2248 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002249 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002250 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002251 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002252 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002253 if ags.retryNumber != originalRetryNumber {
2254 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002255 msgs = append(msgs, AgentMessage{
2256 Type: AutoMessageType,
2257 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002258 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 +00002259 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002260 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002261 }
2262 }
2263
2264 // If we found new commits, create a message
2265 if len(commits) > 0 {
2266 msg := AgentMessage{
2267 Type: CommitMessageType,
2268 Timestamp: time.Now(),
2269 Commits: commits,
2270 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002271 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002272 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002273 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002274}
2275
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002276func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002277 return strings.Map(func(r rune) rune {
2278 // lowercase
2279 if r >= 'A' && r <= 'Z' {
2280 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002281 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002282 // replace spaces with dashes
2283 if r == ' ' {
2284 return '-'
2285 }
2286 // allow alphanumerics and dashes
2287 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2288 return r
2289 }
2290 return -1
2291 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002292}
2293
2294// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2295// and returns an array of GitCommit structs.
2296func parseGitLog(output string) []GitCommit {
2297 var commits []GitCommit
2298
2299 // No output means no commits
2300 if len(output) == 0 {
2301 return commits
2302 }
2303
2304 // Split by NULL byte
2305 parts := strings.Split(output, "\x00")
2306
2307 // Process in triplets (hash, subject, body)
2308 for i := 0; i < len(parts); i++ {
2309 // Skip empty parts
2310 if parts[i] == "" {
2311 continue
2312 }
2313
2314 // This should be a hash
2315 hash := strings.TrimSpace(parts[i])
2316
2317 // Make sure we have at least a subject part available
2318 if i+1 >= len(parts) {
2319 break // No more parts available
2320 }
2321
2322 // Get the subject
2323 subject := strings.TrimSpace(parts[i+1])
2324
2325 // Get the body if available
2326 body := ""
2327 if i+2 < len(parts) {
2328 body = strings.TrimSpace(parts[i+2])
2329 }
2330
2331 // Skip to the next triplet
2332 i += 2
2333
2334 commits = append(commits, GitCommit{
2335 Hash: hash,
2336 Subject: subject,
2337 Body: body,
2338 })
2339 }
2340
2341 return commits
2342}
2343
2344func repoRoot(ctx context.Context, dir string) (string, error) {
2345 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2346 stderr := new(strings.Builder)
2347 cmd.Stderr = stderr
2348 cmd.Dir = dir
2349 out, err := cmd.Output()
2350 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002351 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002352 }
2353 return strings.TrimSpace(string(out)), nil
2354}
2355
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002356// upsertRemoteOrigin configures the origin remote to point to the given URL.
2357// If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002358//
2359// NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL.
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002360func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
2361 // Try to set the URL for existing origin remote
2362 cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
2363 cmd.Dir = repoDir
2364 if _, err := cmd.CombinedOutput(); err == nil {
2365 // Success.
2366 return nil
2367 }
2368 // Origin doesn't exist; add it.
2369 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL)
2370 cmd.Dir = repoDir
2371 if out, err := cmd.CombinedOutput(); err != nil {
2372 return fmt.Errorf("failed to add git remote origin: %s: %w", out, err)
2373 }
2374 return nil
2375}
2376
Earl Lee2e463fb2025-04-17 11:22:22 -07002377func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2378 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2379 stderr := new(strings.Builder)
2380 cmd.Stderr = stderr
2381 cmd.Dir = dir
2382 out, err := cmd.Output()
2383 if err != nil {
2384 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2385 }
2386 // TODO: validate that out is valid hex
2387 return strings.TrimSpace(string(out)), nil
2388}
2389
2390// isValidGitSHA validates if a string looks like a valid git SHA hash.
2391// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2392func isValidGitSHA(sha string) bool {
2393 // Git SHA must be a hexadecimal string with at least 4 characters
2394 if len(sha) < 4 || len(sha) > 40 {
2395 return false
2396 }
2397
2398 // Check if the string only contains hexadecimal characters
2399 for _, char := range sha {
2400 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2401 return false
2402 }
2403 }
2404
2405 return true
2406}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002407
Philip Zeyliger64f60462025-06-16 13:57:10 -07002408// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2409func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2410 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2411 cmd.Dir = repoRoot
2412 out, err := cmd.Output()
2413 if err != nil {
2414 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2415 }
2416
2417 var totalAdded, totalRemoved int
2418 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2419 for _, line := range lines {
2420 if line == "" {
2421 continue
2422 }
2423 parts := strings.Fields(line)
2424 if len(parts) < 2 {
2425 continue
2426 }
2427 // Format: <added>\t<removed>\t<filename>
2428 if added, err := strconv.Atoi(parts[0]); err == nil {
2429 totalAdded += added
2430 }
2431 if removed, err := strconv.Atoi(parts[1]); err == nil {
2432 totalRemoved += removed
2433 }
2434 }
2435
2436 return totalAdded, totalRemoved, nil
2437}
2438
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002439// systemPromptData contains the data used to render the system prompt template
2440type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002441 ClientGOOS string
2442 ClientGOARCH string
2443 WorkingDir string
2444 RepoRoot string
2445 InitialCommit string
2446 Codebase *onstart.Codebase
2447 UseSketchWIP bool
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002448 InstallationNudge bool
David Crawshawc886ac52025-06-13 23:40:03 +00002449 Branch string
2450 SpecialInstruction string
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002451 Now string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002452}
2453
2454// renderSystemPrompt renders the system prompt template.
2455func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002456 nowFn := a.now
2457 if nowFn == nil {
2458 nowFn = time.Now
2459 }
2460 now := nowFn()
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002461 data := systemPromptData{
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002462 ClientGOOS: a.config.ClientGOOS,
2463 ClientGOARCH: a.config.ClientGOARCH,
2464 WorkingDir: a.workingDir,
2465 RepoRoot: a.repoRoot,
2466 InitialCommit: a.SketchGitBase(),
2467 Codebase: a.codebase,
2468 UseSketchWIP: a.config.InDocker,
2469 InstallationNudge: a.config.InDocker,
Josh Bleecher Snyder9224eb02025-07-26 04:45:05 +00002470 Now: now.Format(time.DateOnly),
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002471 }
David Crawshawc886ac52025-06-13 23:40:03 +00002472 if now.Month() == time.September && now.Day() == 19 {
Josh Bleecher Snyder783ab312025-07-25 07:22:38 -07002473 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 +00002474 }
2475
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002476 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2477 if err != nil {
2478 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2479 }
2480 buf := new(strings.Builder)
2481 err = tmpl.Execute(buf, data)
2482 if err != nil {
2483 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2484 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002485 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002486 return buf.String()
2487}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002488
2489// StateTransitionIterator provides an iterator over state transitions.
2490type StateTransitionIterator interface {
2491 // Next blocks until a new state transition is available or context is done.
2492 // Returns nil if the context is cancelled.
2493 Next() *StateTransition
2494 // Close removes the listener and cleans up resources.
2495 Close()
2496}
2497
2498// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2499type StateTransitionIteratorImpl struct {
2500 agent *Agent
2501 ctx context.Context
2502 ch chan StateTransition
2503 unsubscribe func()
2504}
2505
2506// Next blocks until a new state transition is available or the context is cancelled.
2507func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2508 select {
2509 case <-s.ctx.Done():
2510 return nil
2511 case transition, ok := <-s.ch:
2512 if !ok {
2513 return nil
2514 }
2515 transitionCopy := transition
2516 return &transitionCopy
2517 }
2518}
2519
2520// Close removes the listener and cleans up resources.
2521func (s *StateTransitionIteratorImpl) Close() {
2522 if s.unsubscribe != nil {
2523 s.unsubscribe()
2524 s.unsubscribe = nil
2525 }
2526}
2527
2528// NewStateTransitionIterator returns an iterator that receives state transitions.
2529func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2530 a.mu.Lock()
2531 defer a.mu.Unlock()
2532
2533 // Create channel to receive state transitions
2534 ch := make(chan StateTransition, 10)
2535
2536 // Add a listener to the state machine
2537 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2538
2539 return &StateTransitionIteratorImpl{
2540 agent: a,
2541 ctx: ctx,
2542 ch: ch,
2543 unsubscribe: unsubscribe,
2544 }
2545}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002546
2547// setupGitHooks creates or updates git hooks in the specified working directory.
2548func setupGitHooks(workingDir string) error {
2549 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2550
2551 _, err := os.Stat(hooksDir)
2552 if os.IsNotExist(err) {
2553 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2554 }
2555 if err != nil {
2556 return fmt.Errorf("error checking git hooks directory: %w", err)
2557 }
2558
2559 // Define the post-commit hook content
2560 postCommitHook := `#!/bin/bash
2561echo "<post_commit_hook>"
2562echo "Please review this commit message and fix it if it is incorrect."
2563echo "This hook only echos the commit message; it does not modify it."
2564echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2565echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002566PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002567echo "</last_commit_message>"
2568echo "</post_commit_hook>"
2569`
2570
2571 // Define the prepare-commit-msg hook content
2572 prepareCommitMsgHook := `#!/bin/bash
2573# Add Co-Authored-By and Change-ID trailers to commit messages
2574# Check if these trailers already exist before adding them
2575
2576commit_file="$1"
2577COMMIT_SOURCE="$2"
2578
2579# Skip for merges, squashes, or when using a commit template
2580if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2581 [ "$COMMIT_SOURCE" = "squash" ]; then
2582 exit 0
2583fi
2584
2585commit_msg=$(cat "$commit_file")
2586
2587needs_co_author=true
2588needs_change_id=true
2589
2590# Check if commit message already has Co-Authored-By trailer
2591if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2592 needs_co_author=false
2593fi
2594
2595# Check if commit message already has Change-ID trailer
2596if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2597 needs_change_id=false
2598fi
2599
2600# Only modify if at least one trailer needs to be added
2601if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002602 # Ensure there's a proper blank line before trailers
2603 if [ -s "$commit_file" ]; then
2604 # Check if file ends with newline by reading last character
2605 last_char=$(tail -c 1 "$commit_file")
2606
2607 if [ "$last_char" != "" ]; then
2608 # File doesn't end with newline - add two newlines (complete line + blank line)
2609 echo "" >> "$commit_file"
2610 echo "" >> "$commit_file"
2611 else
2612 # File ends with newline - check if we already have a blank line
2613 last_line=$(tail -1 "$commit_file")
2614 if [ -n "$last_line" ]; then
2615 # Last line has content - add one newline for blank line
2616 echo "" >> "$commit_file"
2617 fi
2618 # If last line is empty, we already have a blank line - don't add anything
2619 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002620 fi
2621
2622 # Add trailers if needed
2623 if [ "$needs_co_author" = true ]; then
2624 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2625 fi
2626
2627 if [ "$needs_change_id" = true ]; then
2628 change_id=$(openssl rand -hex 8)
2629 echo "Change-ID: s${change_id}k" >> "$commit_file"
2630 fi
2631fi
2632`
2633
2634 // Update or create the post-commit hook
2635 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2636 if err != nil {
2637 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2638 }
2639
2640 // Update or create the prepare-commit-msg hook
2641 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2642 if err != nil {
2643 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2644 }
2645
2646 return nil
2647}
2648
2649// updateOrCreateHook creates a new hook file or updates an existing one
2650// by appending the new content if it doesn't already contain it.
2651func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2652 // Check if the hook already exists
2653 buf, err := os.ReadFile(hookPath)
2654 if os.IsNotExist(err) {
2655 // Hook doesn't exist, create it
2656 err = os.WriteFile(hookPath, []byte(content), 0o755)
2657 if err != nil {
2658 return fmt.Errorf("failed to create hook: %w", err)
2659 }
2660 return nil
2661 }
2662 if err != nil {
2663 return fmt.Errorf("error reading existing hook: %w", err)
2664 }
2665
2666 // Hook exists, check if our content is already in it by looking for a distinctive line
2667 code := string(buf)
2668 if strings.Contains(code, distinctiveLine) {
2669 // Already contains our content, nothing to do
2670 return nil
2671 }
2672
2673 // Append our content to the existing hook
2674 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2675 if err != nil {
2676 return fmt.Errorf("failed to open hook for appending: %w", err)
2677 }
2678 defer f.Close()
2679
2680 // Ensure there's a newline at the end of the existing content if needed
2681 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2682 _, err = f.WriteString("\n")
2683 if err != nil {
2684 return fmt.Errorf("failed to add newline to hook: %w", err)
2685 }
2686 }
2687
2688 // Add a separator before our content
2689 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2690 if err != nil {
2691 return fmt.Errorf("failed to append to hook: %w", err)
2692 }
2693
2694 return nil
2695}
Sean McCullough138ec242025-06-02 22:42:06 +00002696
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002697// configurePassthroughUpstream configures git remotes
2698// Adds an upstream remote pointing to the same as origin
2699// Sets the refspec for upstream and fetch such that both
2700// fetch the upstream's things into refs/remotes/upstream/foo
2701// The typical scenario is:
2702//
2703// github - laptop - sketch container
2704// "upstream" "origin"
2705func (a *Agent) configurePassthroughUpstream(ctx context.Context) error {
2706 // Get the origin remote URL
2707 cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
2708 cmd.Dir = a.workingDir
2709 originURLBytes, err := cmd.CombinedOutput()
2710 if err != nil {
2711 return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err)
2712 }
2713 originURL := strings.TrimSpace(string(originURLBytes))
2714
2715 // Check if upstream remote already exists
2716 cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream")
2717 cmd.Dir = a.workingDir
2718 if _, err := cmd.CombinedOutput(); err != nil {
2719 // upstream remote doesn't exist, create it
2720 cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL)
2721 cmd.Dir = a.workingDir
2722 if out, err := cmd.CombinedOutput(); err != nil {
2723 return fmt.Errorf("failed to add upstream remote: %s: %w", out, err)
2724 }
2725 slog.InfoContext(ctx, "added upstream remote", "url", originURL)
2726 } else {
2727 // upstream remote exists, update its URL
2728 cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL)
2729 cmd.Dir = a.workingDir
2730 if out, err := cmd.CombinedOutput(); err != nil {
2731 return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err)
2732 }
2733 slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL)
2734 }
2735
2736 // Add the upstream refspec to the upstream remote
2737 cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2738 cmd.Dir = a.workingDir
2739 if out, err := cmd.CombinedOutput(); err != nil {
2740 return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err)
2741 }
2742
2743 // Add the same refspec to the origin remote
2744 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2745 cmd.Dir = a.workingDir
2746 if out, err := cmd.CombinedOutput(); err != nil {
2747 return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err)
2748 }
2749
2750 slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL)
2751 return nil
2752}
2753
Philip Zeyliger0113be52025-06-07 23:53:41 +00002754// SkabandAddr returns the skaband address if configured
2755func (a *Agent) SkabandAddr() string {
2756 if a.config.SkabandClient != nil {
2757 return a.config.SkabandClient.Addr()
2758 }
2759 return ""
2760}
bankseanbdc68892025-07-28 17:28:13 -07002761
2762// ExternalMsg represents a message from a source external to the agent/user conversation,
2763// such as the outcome of a github workflow run.
2764type ExternalMessage struct {
2765 MessageType string `json:"message_type"`
2766 Body any `json:"body"`
2767 TextContent string `json:"text_content"`
Sketch🕴️9988c512025-07-31 16:45:04 +04002768}
Giorgi Lekveishvili6a4ca202025-07-05 20:06:27 +04002769
2770// SendInitialMessage sends an LLM-generated initial message
2771func (a *Agent) SendInitialMessage(ctx context.Context) {
2772 introPrompt := `Based on your role as a Sketch coding assistant and the codebase information provided, write a brief, professional introduction to the user:
Sketch🕴️9988c512025-07-31 16:45:04 +040027731. Greet the user and tell them your name. Your name is stored as DODO_AGENT_NAME environment variable.
Giorgi Lekveishvili6a4ca202025-07-05 20:06:27 +040027742. Retrieve and analyze current project's dodo environment.
27753. Give dodo environment summary to the user.
27764. Ask what they'd like to work on. Be concise and helpful.`
2777
2778 // The LLM response will automatically be pushed to outbox via OnResponse()
2779 _, err := a.convo.SendUserTextMessage(introPrompt)
2780 if err != nil {
2781 a.pushToOutbox(ctx, AgentMessage{
2782 Type: AgentMessageType,
2783 Content: "Hello! I'm your Sketch coding assistant. What would you like to work on today?",
2784 EndOfTurn: true,
2785 })
2786 return
2787 }
bankseanbdc68892025-07-28 17:28:13 -07002788}