blob: 561e986957c81dd7a0b36eb2e9c7096ca9df4459 [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"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000024 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000025 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000026 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000027 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070031 "sketch.dev/skabandclient"
Earl Lee2e463fb2025-04-17 11:22:22 -070032)
33
34const (
35 userCancelMessage = "user requested agent to stop handling responses"
36)
37
Philip Zeyligerb7c58752025-05-01 10:10:17 -070038type MessageIterator interface {
39 // Next blocks until the next message is available. It may
40 // return nil if the underlying iterator context is done.
41 Next() *AgentMessage
42 Close()
43}
44
Earl Lee2e463fb2025-04-17 11:22:22 -070045type CodingAgent interface {
46 // Init initializes an agent inside a docker container.
47 Init(AgentInit) error
48
49 // Ready returns a channel closed after Init successfully called.
50 Ready() <-chan struct{}
51
52 // URL reports the HTTP URL of this agent.
53 URL() string
54
55 // UserMessage enqueues a message to the agent and returns immediately.
56 UserMessage(ctx context.Context, msg string)
57
Philip Zeyligerb7c58752025-05-01 10:10:17 -070058 // Returns an iterator that finishes when the context is done and
59 // starts with the given message index.
60 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070061
Philip Zeyligereab12de2025-05-14 02:35:53 +000062 // Returns an iterator that notifies of state transitions until the context is done.
63 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
64
Earl Lee2e463fb2025-04-17 11:22:22 -070065 // Loop begins the agent loop returns only when ctx is cancelled.
66 Loop(ctx context.Context)
67
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000068 // BranchPrefix returns the configured branch prefix
69 BranchPrefix() string
70
philip.zeyliger6d3de482025-06-10 19:38:14 -070071 // LinkToGitHub returns whether GitHub branch linking is enabled
72 LinkToGitHub() bool
73
Sean McCulloughedc88dc2025-04-30 02:55:01 +000074 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070075
76 CancelToolUse(toolUseID string, cause error) error
77
78 // Returns a subset of the agent's message history.
79 Messages(start int, end int) []AgentMessage
80
81 // Returns the current number of messages in the history
82 MessageCount() int
83
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070084 TotalUsage() conversation.CumulativeUsage
85 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070086
Earl Lee2e463fb2025-04-17 11:22:22 -070087 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000088 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070089
90 // Diff returns a unified diff of changes made since the agent was instantiated.
91 // If commit is non-nil, it shows the diff for just that specific commit.
92 Diff(commit *string) (string, error)
93
Philip Zeyliger49edc922025-05-14 09:45:45 -070094 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
95 // starts out as the commit where sketch started, but a user can move it if need
96 // be, for example in the case of a rebase. It is stored as a git tag.
97 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070098
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000099 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
100 // (Typically, this is "sketch-base")
101 SketchGitBaseRef() string
102
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700103 // Slug returns the slug identifier for this session.
104 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700105
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000106 // BranchName returns the git branch name for the conversation.
107 BranchName() string
108
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700109 // IncrementRetryNumber increments the retry number for branch naming conflicts.
110 IncrementRetryNumber()
111
Earl Lee2e463fb2025-04-17 11:22:22 -0700112 // OS returns the operating system of the client.
113 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000114
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000115 // SessionID returns the unique session identifier.
116 SessionID() string
117
philip.zeyliger8773e682025-06-11 21:36:21 -0700118 // SSHConnectionString returns the SSH connection string for the container.
119 SSHConnectionString() string
120
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000121 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700122 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000123
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000124 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
125 OutstandingLLMCallCount() int
126
127 // OutstandingToolCalls returns the names of outstanding tool calls.
128 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000129 OutsideOS() string
130 OutsideHostname() string
131 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000132 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700133
134 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
135 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000136 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
137 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700138
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700139 // IsInContainer returns true if the agent is running in a container
140 IsInContainer() bool
141 // FirstMessageIndex returns the index of the first message in the current conversation
142 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700143
144 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700145 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
146 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700147
148 // CompactConversation compacts the current conversation by generating a summary
149 // and restarting the conversation with that summary as the initial context
150 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000151 // GetPortMonitor returns the port monitor instance for accessing port events
152 GetPortMonitor() *PortMonitor
Philip Zeyliger0113be52025-06-07 23:53:41 +0000153 // SkabandAddr returns the skaband address if configured
154 SkabandAddr() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700155}
156
157type CodingAgentMessageType string
158
159const (
160 UserMessageType CodingAgentMessageType = "user"
161 AgentMessageType CodingAgentMessageType = "agent"
162 ErrorMessageType CodingAgentMessageType = "error"
163 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
164 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700165 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
166 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
167 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700168
169 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
170)
171
172type AgentMessage struct {
173 Type CodingAgentMessageType `json:"type"`
174 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
175 EndOfTurn bool `json:"end_of_turn"`
176
177 Content string `json:"content"`
178 ToolName string `json:"tool_name,omitempty"`
179 ToolInput string `json:"input,omitempty"`
180 ToolResult string `json:"tool_result,omitempty"`
181 ToolError bool `json:"tool_error,omitempty"`
182 ToolCallId string `json:"tool_call_id,omitempty"`
183
184 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
185 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
186
Sean McCulloughd9f13372025-04-21 15:08:49 -0700187 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
188 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
189
Earl Lee2e463fb2025-04-17 11:22:22 -0700190 // Commits is a list of git commits for a commit message
191 Commits []*GitCommit `json:"commits,omitempty"`
192
193 Timestamp time.Time `json:"timestamp"`
194 ConversationID string `json:"conversation_id"`
195 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700196 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700197
198 // Message timing information
199 StartTime *time.Time `json:"start_time,omitempty"`
200 EndTime *time.Time `json:"end_time,omitempty"`
201 Elapsed *time.Duration `json:"elapsed,omitempty"`
202
203 // Turn duration - the time taken for a complete agent turn
204 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
205
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000206 // HideOutput indicates that this message should not be rendered in the UI.
207 // This is useful for subconversations that generate output that shouldn't be shown to the user.
208 HideOutput bool `json:"hide_output,omitempty"`
209
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700210 // TodoContent contains the agent's todo file content when it has changed
211 TodoContent *string `json:"todo_content,omitempty"`
212
Earl Lee2e463fb2025-04-17 11:22:22 -0700213 Idx int `json:"idx"`
214}
215
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000216// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700217func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700218 if convo == nil {
219 m.ConversationID = ""
220 m.ParentConversationID = nil
221 return
222 }
223 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000224 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700225 if convo.Parent != nil {
226 m.ParentConversationID = &convo.Parent.ID
227 }
228}
229
Earl Lee2e463fb2025-04-17 11:22:22 -0700230// GitCommit represents a single git commit for a commit message
231type GitCommit struct {
232 Hash string `json:"hash"` // Full commit hash
233 Subject string `json:"subject"` // Commit subject line
234 Body string `json:"body"` // Full commit message body
235 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
236}
237
238// ToolCall represents a single tool call within an agent message
239type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700240 Name string `json:"name"`
241 Input string `json:"input"`
242 ToolCallId string `json:"tool_call_id"`
243 ResultMessage *AgentMessage `json:"result_message,omitempty"`
244 Args string `json:"args,omitempty"`
245 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700246}
247
248func (a *AgentMessage) Attr() slog.Attr {
249 var attrs []any = []any{
250 slog.String("type", string(a.Type)),
251 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700252 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700253 if a.EndOfTurn {
254 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
255 }
256 if a.Content != "" {
257 attrs = append(attrs, slog.String("content", a.Content))
258 }
259 if a.ToolName != "" {
260 attrs = append(attrs, slog.String("tool_name", a.ToolName))
261 }
262 if a.ToolInput != "" {
263 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
264 }
265 if a.Elapsed != nil {
266 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
267 }
268 if a.TurnDuration != nil {
269 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
270 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700271 if len(a.ToolResult) > 0 {
272 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700273 }
274 if a.ToolError {
275 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
276 }
277 if len(a.ToolCalls) > 0 {
278 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
279 for i, tc := range a.ToolCalls {
280 toolCallAttrs = append(toolCallAttrs, slog.Group(
281 fmt.Sprintf("tool_call_%d", i),
282 slog.String("name", tc.Name),
283 slog.String("input", tc.Input),
284 ))
285 }
286 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
287 }
288 if a.ConversationID != "" {
289 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
290 }
291 if a.ParentConversationID != nil {
292 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
293 }
294 if a.Usage != nil && !a.Usage.IsZero() {
295 attrs = append(attrs, a.Usage.Attr())
296 }
297 // TODO: timestamp, convo ids, idx?
298 return slog.Group("agent_message", attrs...)
299}
300
301func errorMessage(err error) AgentMessage {
302 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
303 if os.Getenv(("DEBUG")) == "1" {
304 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
305 }
306
307 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
308}
309
310func budgetMessage(err error) AgentMessage {
311 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
312}
313
314// ConvoInterface defines the interface for conversation interactions
315type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700316 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700317 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700318 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700320 SendMessage(message llm.Message) (*llm.Response, error)
321 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700322 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000323 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700324 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700325 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700326 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700327}
328
Philip Zeyligerf2872992025-05-22 10:35:28 -0700329// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700330// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700331// any time we notice we need to.
332type AgentGitState struct {
333 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700334 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700335 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000336 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700337 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700338 slug string // Human-readable session identifier
339 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700340 linesAdded int // Lines added from sketch-base to HEAD
341 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700342}
343
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700344func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700345 ags.mu.Lock()
346 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700347 if ags.slug != slug {
348 ags.retryNumber = 0
349 }
350 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700351}
352
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700353func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700354 ags.mu.Lock()
355 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700356 return ags.slug
357}
358
359func (ags *AgentGitState) IncrementRetryNumber() {
360 ags.mu.Lock()
361 defer ags.mu.Unlock()
362 ags.retryNumber++
363}
364
Philip Zeyliger64f60462025-06-16 13:57:10 -0700365func (ags *AgentGitState) DiffStats() (int, int) {
366 ags.mu.Lock()
367 defer ags.mu.Unlock()
368 return ags.linesAdded, ags.linesRemoved
369}
370
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700371// HasSeenCommits returns true if any commits have been processed
372func (ags *AgentGitState) HasSeenCommits() bool {
373 ags.mu.Lock()
374 defer ags.mu.Unlock()
375 return len(ags.seenCommits) > 0
376}
377
378func (ags *AgentGitState) RetryNumber() int {
379 ags.mu.Lock()
380 defer ags.mu.Unlock()
381 return ags.retryNumber
382}
383
384func (ags *AgentGitState) BranchName(prefix string) string {
385 ags.mu.Lock()
386 defer ags.mu.Unlock()
387 return ags.branchNameLocked(prefix)
388}
389
390func (ags *AgentGitState) branchNameLocked(prefix string) string {
391 if ags.slug == "" {
392 return ""
393 }
394 if ags.retryNumber == 0 {
395 return prefix + ags.slug
396 }
397 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700398}
399
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000400func (ags *AgentGitState) Upstream() string {
401 ags.mu.Lock()
402 defer ags.mu.Unlock()
403 return ags.upstream
404}
405
Earl Lee2e463fb2025-04-17 11:22:22 -0700406type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700407 convo ConvoInterface
408 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700409 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700410 workingDir string
411 repoRoot string // workingDir may be a subdir of repoRoot
412 url string
413 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000414 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700415 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000416 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700417 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700418 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000419 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700420 // State machine to track agent state
421 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000422 // Outside information
423 outsideHostname string
424 outsideOS string
425 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000426 // URL of the git remote 'origin' if it exists
427 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700428
429 // Time when the current turn started (reset at the beginning of InnerLoop)
430 startOfTurn time.Time
431
432 // Inbox - for messages from the user to the agent.
433 // sent on by UserMessage
434 // . e.g. when user types into the chat textarea
435 // read from by GatherMessages
436 inbox chan string
437
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000438 // protects cancelTurn
439 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700440 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000441 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700442
443 // protects following
444 mu sync.Mutex
445
446 // Stores all messages for this agent
447 history []AgentMessage
448
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700449 // Iterators add themselves here when they're ready to be notified of new messages.
450 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700451
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000452 // Track outstanding LLM call IDs
453 outstandingLLMCalls map[string]struct{}
454
455 // Track outstanding tool calls by ID with their names
456 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000457
458 // Port monitoring
459 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700460}
461
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700462// NewIterator implements CodingAgent.
463func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
464 a.mu.Lock()
465 defer a.mu.Unlock()
466
467 return &MessageIteratorImpl{
468 agent: a,
469 ctx: ctx,
470 nextMessageIdx: nextMessageIdx,
471 ch: make(chan *AgentMessage, 100),
472 }
473}
474
475type MessageIteratorImpl struct {
476 agent *Agent
477 ctx context.Context
478 nextMessageIdx int
479 ch chan *AgentMessage
480 subscribed bool
481}
482
483func (m *MessageIteratorImpl) Close() {
484 m.agent.mu.Lock()
485 defer m.agent.mu.Unlock()
486 // Delete ourselves from the subscribers list
487 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
488 return x == m.ch
489 })
490 close(m.ch)
491}
492
493func (m *MessageIteratorImpl) Next() *AgentMessage {
494 // We avoid subscription at creation to let ourselves catch up to "current state"
495 // before subscribing.
496 if !m.subscribed {
497 m.agent.mu.Lock()
498 if m.nextMessageIdx < len(m.agent.history) {
499 msg := &m.agent.history[m.nextMessageIdx]
500 m.nextMessageIdx++
501 m.agent.mu.Unlock()
502 return msg
503 }
504 // The next message doesn't exist yet, so let's subscribe
505 m.agent.subscribers = append(m.agent.subscribers, m.ch)
506 m.subscribed = true
507 m.agent.mu.Unlock()
508 }
509
510 for {
511 select {
512 case <-m.ctx.Done():
513 m.agent.mu.Lock()
514 // Delete ourselves from the subscribers list
515 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
516 return x == m.ch
517 })
518 m.subscribed = false
519 m.agent.mu.Unlock()
520 return nil
521 case msg, ok := <-m.ch:
522 if !ok {
523 // Close may have been called
524 return nil
525 }
526 if msg.Idx == m.nextMessageIdx {
527 m.nextMessageIdx++
528 return msg
529 }
530 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
531 panic("out of order message")
532 }
533 }
534}
535
Sean McCulloughd9d45812025-04-30 16:53:41 -0700536// Assert that Agent satisfies the CodingAgent interface.
537var _ CodingAgent = &Agent{}
538
539// StateName implements CodingAgent.
540func (a *Agent) CurrentStateName() string {
541 if a.stateMachine == nil {
542 return ""
543 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000544 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700545}
546
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700547// CurrentTodoContent returns the current todo list data as JSON.
548// It returns an empty string if no todos exist.
549func (a *Agent) CurrentTodoContent() string {
550 todoPath := claudetool.TodoFilePath(a.config.SessionID)
551 content, err := os.ReadFile(todoPath)
552 if err != nil {
553 return ""
554 }
555 return string(content)
556}
557
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700558// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
559func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
560 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.
561
562IMPORTANT: 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.
563
564Please create a detailed summary that includes:
565
5661. **User's Request**: What did the user originally ask me to do? What was their goal?
567
5682. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
569
5703. **Key Technical Decisions**: What important technical choices were made during our work and why?
571
5724. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
573
5745. **Next Steps**: What still needs to be done to complete the user's request?
575
5766. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
577
578Focus 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.
579
580Reply with ONLY the summary content - no meta-commentary about creating the summary.`
581
582 userMessage := llm.UserStringMessage(msg)
583 // Use a subconversation with history to get the summary
584 // TODO: We don't have any tools here, so we should have enough tokens
585 // to capture a summary, but we may need to modify the history (e.g., remove
586 // TODO data) to save on some tokens.
587 convo := a.convo.SubConvoWithHistory()
588
589 // Modify the system prompt to provide context about the original task
590 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000591 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 -0700592
593Your 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.
594
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000595Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700596
597 resp, err := convo.SendMessage(userMessage)
598 if err != nil {
599 a.pushToOutbox(ctx, errorMessage(err))
600 return "", err
601 }
602 textContent := collectTextContent(resp)
603
604 // Restore original system prompt (though this subconvo will be discarded)
605 convo.SystemPrompt = originalSystemPrompt
606
607 return textContent, nil
608}
609
610// CompactConversation compacts the current conversation by generating a summary
611// and restarting the conversation with that summary as the initial context
612func (a *Agent) CompactConversation(ctx context.Context) error {
613 summary, err := a.generateConversationSummary(ctx)
614 if err != nil {
615 return fmt.Errorf("failed to generate conversation summary: %w", err)
616 }
617
618 a.mu.Lock()
619
620 // Get usage information before resetting conversation
621 lastUsage := a.convo.LastUsage()
622 contextWindow := a.config.Service.TokenContextWindow()
623 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
624
625 // Reset conversation state but keep all other state (git, working dir, etc.)
626 a.firstMessageIndex = len(a.history)
627 a.convo = a.initConvo()
628
629 a.mu.Unlock()
630
631 // Create informative compaction message with token details
632 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
633 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
634 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
635
636 a.pushToOutbox(ctx, AgentMessage{
637 Type: CompactMessageType,
638 Content: compactionMsg,
639 })
640
641 a.pushToOutbox(ctx, AgentMessage{
642 Type: UserMessageType,
643 Content: fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nPlease continue with the work based on this summary.", summary),
644 })
645 a.inbox <- fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nPlease continue with the work based on this summary.", summary)
646
647 return nil
648}
649
Earl Lee2e463fb2025-04-17 11:22:22 -0700650func (a *Agent) URL() string { return a.url }
651
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000652// BranchName returns the git branch name for the conversation.
653func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700654 return a.gitState.BranchName(a.config.BranchPrefix)
655}
656
657// Slug returns the slug identifier for this conversation.
658func (a *Agent) Slug() string {
659 return a.gitState.Slug()
660}
661
662// IncrementRetryNumber increments the retry number for branch naming conflicts
663func (a *Agent) IncrementRetryNumber() {
664 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000665}
666
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000667// OutstandingLLMCallCount returns the number of outstanding LLM calls.
668func (a *Agent) OutstandingLLMCallCount() int {
669 a.mu.Lock()
670 defer a.mu.Unlock()
671 return len(a.outstandingLLMCalls)
672}
673
674// OutstandingToolCalls returns the names of outstanding tool calls.
675func (a *Agent) OutstandingToolCalls() []string {
676 a.mu.Lock()
677 defer a.mu.Unlock()
678
679 tools := make([]string, 0, len(a.outstandingToolCalls))
680 for _, toolName := range a.outstandingToolCalls {
681 tools = append(tools, toolName)
682 }
683 return tools
684}
685
Earl Lee2e463fb2025-04-17 11:22:22 -0700686// OS returns the operating system of the client.
687func (a *Agent) OS() string {
688 return a.config.ClientGOOS
689}
690
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000691func (a *Agent) SessionID() string {
692 return a.config.SessionID
693}
694
philip.zeyliger8773e682025-06-11 21:36:21 -0700695// SSHConnectionString returns the SSH connection string for the container.
696func (a *Agent) SSHConnectionString() string {
697 return a.config.SSHConnectionString
698}
699
Philip Zeyliger18532b22025-04-23 21:11:46 +0000700// OutsideOS returns the operating system of the outside system.
701func (a *Agent) OutsideOS() string {
702 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000703}
704
Philip Zeyliger18532b22025-04-23 21:11:46 +0000705// OutsideHostname returns the hostname of the outside system.
706func (a *Agent) OutsideHostname() string {
707 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000708}
709
Philip Zeyliger18532b22025-04-23 21:11:46 +0000710// OutsideWorkingDir returns the working directory on the outside system.
711func (a *Agent) OutsideWorkingDir() string {
712 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000713}
714
715// GitOrigin returns the URL of the git remote 'origin' if it exists.
716func (a *Agent) GitOrigin() string {
717 return a.gitOrigin
718}
719
Philip Zeyliger64f60462025-06-16 13:57:10 -0700720// DiffStats returns the number of lines added and removed from sketch-base to HEAD
721func (a *Agent) DiffStats() (int, int) {
722 return a.gitState.DiffStats()
723}
724
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000725func (a *Agent) OpenBrowser(url string) {
726 if !a.IsInContainer() {
727 browser.Open(url)
728 return
729 }
730 // We're in Docker, need to send a request to the Git server
731 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700732 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000733 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700734 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000735 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700736 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000737 return
738 }
739 defer resp.Body.Close()
740 if resp.StatusCode == http.StatusOK {
741 return
742 }
743 body, _ := io.ReadAll(resp.Body)
744 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
745}
746
Sean McCullough96b60dd2025-04-30 09:49:10 -0700747// CurrentState returns the current state of the agent's state machine.
748func (a *Agent) CurrentState() State {
749 return a.stateMachine.CurrentState()
750}
751
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700752func (a *Agent) IsInContainer() bool {
753 return a.config.InDocker
754}
755
756func (a *Agent) FirstMessageIndex() int {
757 a.mu.Lock()
758 defer a.mu.Unlock()
759 return a.firstMessageIndex
760}
761
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700762// SetSlug sets a human-readable identifier for the conversation.
763func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700764 a.mu.Lock()
765 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700766
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700767 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000768 convo, ok := a.convo.(*conversation.Convo)
769 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700770 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000771 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700772}
773
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000774// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700775func (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 +0000776 // Track the tool call
777 a.mu.Lock()
778 a.outstandingToolCalls[id] = toolName
779 a.mu.Unlock()
780}
781
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700782// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
783// If there's only one element in the array and it's a text type, it returns that text directly.
784// It also processes nested ToolResult arrays recursively.
785func contentToString(contents []llm.Content) string {
786 if len(contents) == 0 {
787 return ""
788 }
789
790 // If there's only one element and it's a text type, return it directly
791 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
792 return contents[0].Text
793 }
794
795 // Otherwise, concatenate all text content
796 var result strings.Builder
797 for _, content := range contents {
798 if content.Type == llm.ContentTypeText {
799 result.WriteString(content.Text)
800 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
801 // Recursively process nested tool results
802 result.WriteString(contentToString(content.ToolResult))
803 }
804 }
805
806 return result.String()
807}
808
Earl Lee2e463fb2025-04-17 11:22:22 -0700809// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700810func (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 +0000811 // Remove the tool call from outstanding calls
812 a.mu.Lock()
813 delete(a.outstandingToolCalls, toolID)
814 a.mu.Unlock()
815
Earl Lee2e463fb2025-04-17 11:22:22 -0700816 m := AgentMessage{
817 Type: ToolUseMessageType,
818 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700819 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700820 ToolError: content.ToolError,
821 ToolName: toolName,
822 ToolInput: string(toolInput),
823 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700824 StartTime: content.ToolUseStartTime,
825 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700826 }
827
828 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700829 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
830 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700831 m.Elapsed = &elapsed
832 }
833
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700834 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700835 a.pushToOutbox(ctx, m)
836}
837
838// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700839func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000840 a.mu.Lock()
841 defer a.mu.Unlock()
842 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700843 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
844}
845
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700846// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700847// that need to be displayed (as well as tool calls that we send along when
848// they're done). (It would be reasonable to also mention tool calls when they're
849// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700850func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000851 // Remove the LLM call from outstanding calls
852 a.mu.Lock()
853 delete(a.outstandingLLMCalls, id)
854 a.mu.Unlock()
855
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700856 if resp == nil {
857 // LLM API call failed
858 m := AgentMessage{
859 Type: ErrorMessageType,
860 Content: "API call failed, type 'continue' to try again",
861 }
862 m.SetConvo(convo)
863 a.pushToOutbox(ctx, m)
864 return
865 }
866
Earl Lee2e463fb2025-04-17 11:22:22 -0700867 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700868 if convo.Parent == nil { // subconvos never end the turn
869 switch resp.StopReason {
870 case llm.StopReasonToolUse:
871 // Check whether any of the tool calls are for tools that should end the turn
872 ToolSearch:
873 for _, part := range resp.Content {
874 if part.Type != llm.ContentTypeToolUse {
875 continue
876 }
Sean McCullough021557a2025-05-05 23:20:53 +0000877 // Find the tool by name
878 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700879 if tool.Name == part.ToolName {
880 endOfTurn = tool.EndsTurn
881 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000882 }
883 }
Sean McCullough021557a2025-05-05 23:20:53 +0000884 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700885 default:
886 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000887 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700888 }
889 m := AgentMessage{
890 Type: AgentMessageType,
891 Content: collectTextContent(resp),
892 EndOfTurn: endOfTurn,
893 Usage: &resp.Usage,
894 StartTime: resp.StartTime,
895 EndTime: resp.EndTime,
896 }
897
898 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700899 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700900 var toolCalls []ToolCall
901 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700902 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700903 toolCalls = append(toolCalls, ToolCall{
904 Name: part.ToolName,
905 Input: string(part.ToolInput),
906 ToolCallId: part.ID,
907 })
908 }
909 }
910 m.ToolCalls = toolCalls
911 }
912
913 // Calculate the elapsed time if both start and end times are set
914 if resp.StartTime != nil && resp.EndTime != nil {
915 elapsed := resp.EndTime.Sub(*resp.StartTime)
916 m.Elapsed = &elapsed
917 }
918
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700919 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700920 a.pushToOutbox(ctx, m)
921}
922
923// WorkingDir implements CodingAgent.
924func (a *Agent) WorkingDir() string {
925 return a.workingDir
926}
927
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000928// RepoRoot returns the git repository root directory.
929func (a *Agent) RepoRoot() string {
930 return a.repoRoot
931}
932
Earl Lee2e463fb2025-04-17 11:22:22 -0700933// MessageCount implements CodingAgent.
934func (a *Agent) MessageCount() int {
935 a.mu.Lock()
936 defer a.mu.Unlock()
937 return len(a.history)
938}
939
940// Messages implements CodingAgent.
941func (a *Agent) Messages(start int, end int) []AgentMessage {
942 a.mu.Lock()
943 defer a.mu.Unlock()
944 return slices.Clone(a.history[start:end])
945}
946
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700947// ShouldCompact checks if the conversation should be compacted based on token usage
948func (a *Agent) ShouldCompact() bool {
949 // Get the threshold from environment variable, default to 0.94 (94%)
950 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
951 // and a little bit of buffer.)
952 thresholdRatio := 0.94
953 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
954 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
955 thresholdRatio = parsed
956 }
957 }
958
959 // Get the most recent usage to check current context size
960 lastUsage := a.convo.LastUsage()
961
962 if lastUsage.InputTokens == 0 {
963 // No API calls made yet
964 return false
965 }
966
967 // Calculate the current context size from the last API call
968 // This includes all tokens that were part of the input context:
969 // - Input tokens (user messages, system prompt, conversation history)
970 // - Cache read tokens (cached parts of the context)
971 // - Cache creation tokens (new parts being cached)
972 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
973
974 // Get the service's token context window
975 service := a.config.Service
976 contextWindow := service.TokenContextWindow()
977
978 // Calculate threshold
979 threshold := uint64(float64(contextWindow) * thresholdRatio)
980
981 // Check if we've exceeded the threshold
982 return currentContextSize >= threshold
983}
984
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700985func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700986 return a.originalBudget
987}
988
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000989// Upstream returns the upstream branch for git work
990func (a *Agent) Upstream() string {
991 return a.gitState.Upstream()
992}
993
Earl Lee2e463fb2025-04-17 11:22:22 -0700994// AgentConfig contains configuration for creating a new Agent.
995type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000996 Context context.Context
997 Service llm.Service
998 Budget conversation.Budget
999 GitUsername string
1000 GitEmail string
1001 SessionID string
1002 ClientGOOS string
1003 ClientGOARCH string
1004 InDocker bool
1005 OneShot bool
1006 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001007 // Outside information
1008 OutsideHostname string
1009 OutsideOS string
1010 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001011
1012 // Outtie's HTTP to, e.g., open a browser
1013 OutsideHTTP string
1014 // Outtie's Git server
1015 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001016 // Upstream branch for git work
1017 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001018 // Commit to checkout from Outtie
1019 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001020 // Prefix for git branches created by sketch
1021 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001022 // LinkToGitHub enables GitHub branch linking in UI
1023 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001024 // SSH connection string for connecting to the container
1025 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001026 // Skaband client for session history (optional)
1027 SkabandClient *skabandclient.SkabandClient
Earl Lee2e463fb2025-04-17 11:22:22 -07001028}
1029
1030// NewAgent creates a new Agent.
1031// It is not usable until Init() is called.
1032func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001033 // Set default branch prefix if not specified
1034 if config.BranchPrefix == "" {
1035 config.BranchPrefix = "sketch/"
1036 }
1037
Earl Lee2e463fb2025-04-17 11:22:22 -07001038 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001039 config: config,
1040 ready: make(chan struct{}),
1041 inbox: make(chan string, 100),
1042 subscribers: make([]chan *AgentMessage, 0),
1043 startedAt: time.Now(),
1044 originalBudget: config.Budget,
1045 gitState: AgentGitState{
1046 seenCommits: make(map[string]bool),
1047 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001048 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001049 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001050 outsideHostname: config.OutsideHostname,
1051 outsideOS: config.OutsideOS,
1052 outsideWorkingDir: config.OutsideWorkingDir,
1053 outstandingLLMCalls: make(map[string]struct{}),
1054 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001055 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001056 workingDir: config.WorkingDir,
1057 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +00001058 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001059 }
1060 return agent
1061}
1062
1063type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001064 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001065
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001066 InDocker bool
1067 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001068}
1069
1070func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001071 if a.convo != nil {
1072 return fmt.Errorf("Agent.Init: already initialized")
1073 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001074 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001075 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001076
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001077 if !ini.NoGit {
1078 // Capture the original origin before we potentially replace it below
1079 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
1080 }
1081
Philip Zeyliger222bf412025-06-04 16:42:58 +00001082 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001083 if a.gitState.gitRemoteAddr != "" {
1084 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001085
1086 // Remove existing origin remote if it exists
1087 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001088 cmd.Dir = a.workingDir
1089 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001090 // Ignore error if origin doesn't exist
1091 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001092 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001093
1094 // Add the new remote as origin
1095 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001096 cmd.Dir = a.workingDir
1097 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001098 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001099 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001100
Philip Zeyligerf2872992025-05-22 10:35:28 -07001101 }
1102
1103 // If a commit was specified, we fetch and reset to it.
1104 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001105 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1106
Earl Lee2e463fb2025-04-17 11:22:22 -07001107 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001108 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001109 if out, err := cmd.CombinedOutput(); err != nil {
1110 return fmt.Errorf("git stash: %s: %v", out, err)
1111 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001112 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001113 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001114 if out, err := cmd.CombinedOutput(); err != nil {
1115 return fmt.Errorf("git fetch: %s: %w", out, err)
1116 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001117 // The -B resets the branch if it already exists (or creates it if it doesn't)
1118 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001119 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001120 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1121 // Remove git hooks if they exist and retry
1122 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001123 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001124 if _, statErr := os.Stat(hookPath); statErr == nil {
1125 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1126 slog.String("error", err.Error()),
1127 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001128 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001129 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1130 }
1131
1132 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001133 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001134 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001135 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001136 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 +01001137 }
1138 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001139 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001140 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001141 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001142 } else if a.IsInContainer() {
1143 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1144 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1145 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1146 cmd.Dir = a.workingDir
1147 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1148 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1149 }
1150 } else {
1151 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001152 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001153
1154 if ini.HostAddr != "" {
1155 a.url = "http://" + ini.HostAddr
1156 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001157
1158 if !ini.NoGit {
1159 repoRoot, err := repoRoot(ctx, a.workingDir)
1160 if err != nil {
1161 return fmt.Errorf("repoRoot: %w", err)
1162 }
1163 a.repoRoot = repoRoot
1164
Earl Lee2e463fb2025-04-17 11:22:22 -07001165 if err != nil {
1166 return fmt.Errorf("resolveRef: %w", err)
1167 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001168
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001169 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001170 if err := setupGitHooks(a.repoRoot); err != nil {
1171 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1172 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001173 }
1174
Philip Zeyliger49edc922025-05-14 09:45:45 -07001175 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1176 cmd.Dir = repoRoot
1177 if out, err := cmd.CombinedOutput(); err != nil {
1178 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1179 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001180
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001181 slog.Info("running codebase analysis")
1182 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1183 if err != nil {
1184 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001185 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001186 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001187
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001188 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001189 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001190 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001191 }
1192 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001193
Earl Lee2e463fb2025-04-17 11:22:22 -07001194 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001195 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001196 a.convo = a.initConvo()
1197 close(a.ready)
1198 return nil
1199}
1200
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001201//go:embed agent_system_prompt.txt
1202var agentSystemPrompt string
1203
Earl Lee2e463fb2025-04-17 11:22:22 -07001204// initConvo initializes the conversation.
1205// It must not be called until all agent fields are initialized,
1206// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001207func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001208 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001209 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001210 convo.PromptCaching = true
1211 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001212 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001213 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001214
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001215 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1216 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001217 if a.gitState.Slug() != "" {
1218 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001219 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001220 willCommit, err := bashkit.WillRunGitCommit(command)
1221 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001222 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001223 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001224 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001225 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001226 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001227 return nil
1228 }
1229
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001230 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001231
Earl Lee2e463fb2025-04-17 11:22:22 -07001232 // Register all tools with the conversation
1233 // When adding, removing, or modifying tools here, double-check that the termui tool display
1234 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001235
1236 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001237 _, supportsScreenshots := a.config.Service.(*ant.Service)
1238 var bTools []*llm.Tool
1239 var browserCleanup func()
1240
1241 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1242 // Add cleanup function to context cancel
1243 go func() {
1244 <-a.config.Context.Done()
1245 browserCleanup()
1246 }()
1247 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001248
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001249 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001250 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001251 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001252 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001253 }
1254
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001255 // One-shot mode is non-interactive, multiple choice requires human response
1256 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001257 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001258 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001259
1260 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001261
1262 // Add session history tools if skaband client is available
1263 if a.config.SkabandClient != nil {
1264 sessionHistoryTools := claudetool.CreateSessionHistoryTools(a.config.SkabandClient, a.config.SessionID, a.gitOrigin)
1265 convo.Tools = append(convo.Tools, sessionHistoryTools...)
1266 }
1267
Earl Lee2e463fb2025-04-17 11:22:22 -07001268 convo.Listener = a
1269 return convo
1270}
1271
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001272var multipleChoiceTool = &llm.Tool{
1273 Name: "multiplechoice",
1274 Description: "Present the user with an quick way to answer to your question using one of a short list of possible answers you would expect from the user.",
1275 EndsTurn: true,
1276 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001277 "type": "object",
1278 "description": "The question and a list of answers you would expect the user to choose from.",
1279 "properties": {
1280 "question": {
1281 "type": "string",
1282 "description": "The text of the multiple-choice question you would like the user, e.g. 'What kinds of test cases would you like me to add?'"
1283 },
1284 "responseOptions": {
1285 "type": "array",
1286 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1287 "items": {
1288 "type": "object",
1289 "properties": {
1290 "caption": {
1291 "type": "string",
1292 "description": "The caption, e.g. 'Basic coverage', 'Error return values', or 'Malformed input' for the response button. Do NOT include options for responses that would end the conversation like 'Ok', 'No thank you', 'This looks good'"
1293 },
1294 "responseText": {
1295 "type": "string",
1296 "description": "The full text of the response, e.g. 'Add unit tests for basic test coverage', 'Add unit tests for error return values', or 'Add unit tests for malformed input'"
1297 }
1298 },
1299 "required": ["caption", "responseText"]
1300 }
1301 }
1302 },
1303 "required": ["question", "responseOptions"]
1304}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001305 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1306 // The Run logic for "multiplechoice" tool is a no-op on the server.
1307 // The UI will present a list of options for the user to select from,
1308 // and that's it as far as "executing" the tool_use goes.
1309 // When the user *does* select one of the presented options, that
1310 // responseText gets sent as a chat message on behalf of the user.
1311 return llm.TextContent("end your turn and wait for the user to respond"), nil
1312 },
Sean McCullough485afc62025-04-28 14:28:39 -07001313}
1314
1315type MultipleChoiceOption struct {
1316 Caption string `json:"caption"`
1317 ResponseText string `json:"responseText"`
1318}
1319
1320type MultipleChoiceParams struct {
1321 Question string `json:"question"`
1322 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1323}
1324
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001325// branchExists reports whether branchName exists, either locally or in well-known remotes.
1326func branchExists(dir, branchName string) bool {
1327 refs := []string{
1328 "refs/heads/",
1329 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001330 }
1331 for _, ref := range refs {
1332 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1333 cmd.Dir = dir
1334 if cmd.Run() == nil { // exit code 0 means branch exists
1335 return true
1336 }
1337 }
1338 return false
1339}
1340
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001341func (a *Agent) setSlugTool() *llm.Tool {
1342 return &llm.Tool{
1343 Name: "set-slug",
1344 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001345 InputSchema: json.RawMessage(`{
1346 "type": "object",
1347 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001348 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001349 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001350 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001351 }
1352 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001353 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001354}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001355 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001356 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001357 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001358 }
1359 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001360 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001361 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001362 // Prevent slug changes if there have been git changes
1363 // This lets the agent change its mind about a good slug,
1364 // while ensuring that once a branch has been pushed, it remains stable.
1365 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1366 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001367 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001368 if params.Slug == "" {
1369 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001370 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001371 slug := cleanSlugName(params.Slug)
1372 if slug == "" {
1373 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1374 }
1375 a.SetSlug(slug)
1376 // TODO: do this by a call to outie, rather than semi-guessing from innie
1377 if branchExists(a.workingDir, a.BranchName()) {
1378 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1379 }
1380 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001381 },
1382 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001383}
1384
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001385func (a *Agent) commitMessageStyleTool() *llm.Tool {
1386 description := `Provides git commit message style guidance. MANDATORY: You must use this tool before making any git commits.`
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001387 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001388 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001389 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001390 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001391 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001392 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1393 if err != nil {
1394 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1395 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001396 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001397 },
1398 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001399 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001400}
1401
1402func (a *Agent) Ready() <-chan struct{} {
1403 return a.ready
1404}
1405
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001406// BranchPrefix returns the configured branch prefix
1407func (a *Agent) BranchPrefix() string {
1408 return a.config.BranchPrefix
1409}
1410
philip.zeyliger6d3de482025-06-10 19:38:14 -07001411// LinkToGitHub returns whether GitHub branch linking is enabled
1412func (a *Agent) LinkToGitHub() bool {
1413 return a.config.LinkToGitHub
1414}
1415
Earl Lee2e463fb2025-04-17 11:22:22 -07001416func (a *Agent) UserMessage(ctx context.Context, msg string) {
1417 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1418 a.inbox <- msg
1419}
1420
Earl Lee2e463fb2025-04-17 11:22:22 -07001421func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1422 return a.convo.CancelToolUse(toolUseID, cause)
1423}
1424
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001425func (a *Agent) CancelTurn(cause error) {
1426 a.cancelTurnMu.Lock()
1427 defer a.cancelTurnMu.Unlock()
1428 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001429 // Force state transition to cancelled state
1430 ctx := a.config.Context
1431 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001432 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001433 }
1434}
1435
1436func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001437 // Start port monitoring when the agent loop begins
1438 // Only monitor ports when running in a container
1439 if a.IsInContainer() {
1440 a.portMonitor.Start(ctxOuter)
1441 }
1442
Earl Lee2e463fb2025-04-17 11:22:22 -07001443 for {
1444 select {
1445 case <-ctxOuter.Done():
1446 return
1447 default:
1448 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001449 a.cancelTurnMu.Lock()
1450 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001451 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001452 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001453 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001454 a.cancelTurn = cancel
1455 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001456 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1457 if err != nil {
1458 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1459 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001460 cancel(nil)
1461 }
1462 }
1463}
1464
1465func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1466 if m.Timestamp.IsZero() {
1467 m.Timestamp = time.Now()
1468 }
1469
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001470 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1471 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1472 m.Content = m.ToolResult
1473 }
1474
Earl Lee2e463fb2025-04-17 11:22:22 -07001475 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1476 if m.EndOfTurn && m.Type == AgentMessageType {
1477 turnDuration := time.Since(a.startOfTurn)
1478 m.TurnDuration = &turnDuration
1479 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1480 }
1481
Earl Lee2e463fb2025-04-17 11:22:22 -07001482 a.mu.Lock()
1483 defer a.mu.Unlock()
1484 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001485 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001486 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001487
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001488 // Notify all subscribers
1489 for _, ch := range a.subscribers {
1490 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001491 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001492}
1493
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001494func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1495 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001496 if block {
1497 select {
1498 case <-ctx.Done():
1499 return m, ctx.Err()
1500 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001501 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001502 }
1503 }
1504 for {
1505 select {
1506 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001507 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001508 default:
1509 return m, nil
1510 }
1511 }
1512}
1513
Sean McCullough885a16a2025-04-30 02:49:25 +00001514// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001515func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001516 // Reset the start of turn time
1517 a.startOfTurn = time.Now()
1518
Sean McCullough96b60dd2025-04-30 09:49:10 -07001519 // Transition to waiting for user input state
1520 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1521
Sean McCullough885a16a2025-04-30 02:49:25 +00001522 // Process initial user message
1523 initialResp, err := a.processUserMessage(ctx)
1524 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001525 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001526 return err
1527 }
1528
1529 // Handle edge case where both initialResp and err are nil
1530 if initialResp == nil {
1531 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001532 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1533
Sean McCullough9f4b8082025-04-30 17:34:07 +00001534 a.pushToOutbox(ctx, errorMessage(err))
1535 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001536 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001537
Earl Lee2e463fb2025-04-17 11:22:22 -07001538 // We do this as we go, but let's also do it at the end of the turn
1539 defer func() {
1540 if _, err := a.handleGitCommits(ctx); err != nil {
1541 // Just log the error, don't stop execution
1542 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1543 }
1544 }()
1545
Sean McCullougha1e0e492025-05-01 10:51:08 -07001546 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001547 resp := initialResp
1548 for {
1549 // Check if we are over budget
1550 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001551 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001552 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001553 }
1554
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001555 // Check if we should compact the conversation
1556 if a.ShouldCompact() {
1557 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1558 if err := a.CompactConversation(ctx); err != nil {
1559 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1560 return err
1561 }
1562 // After compaction, end this turn and start fresh
1563 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1564 return nil
1565 }
1566
Sean McCullough885a16a2025-04-30 02:49:25 +00001567 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001568 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001569 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001570 break
1571 }
1572
Sean McCullough96b60dd2025-04-30 09:49:10 -07001573 // Transition to tool use requested state
1574 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1575
Sean McCullough885a16a2025-04-30 02:49:25 +00001576 // Handle tool execution
1577 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1578 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001579 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001580 }
1581
Sean McCullougha1e0e492025-05-01 10:51:08 -07001582 if toolResp == nil {
1583 return fmt.Errorf("cannot continue conversation with a nil tool response")
1584 }
1585
Sean McCullough885a16a2025-04-30 02:49:25 +00001586 // Set the response for the next iteration
1587 resp = toolResp
1588 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001589
1590 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001591}
1592
1593// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001594func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001595 // Wait for at least one message from the user
1596 msgs, err := a.GatherMessages(ctx, true)
1597 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001598 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001599 return nil, err
1600 }
1601
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001602 userMessage := llm.Message{
1603 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001604 Content: msgs,
1605 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001606
Sean McCullough96b60dd2025-04-30 09:49:10 -07001607 // Transition to sending to LLM state
1608 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1609
Sean McCullough885a16a2025-04-30 02:49:25 +00001610 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001611 resp, err := a.convo.SendMessage(userMessage)
1612 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001613 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001614 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001615 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001616 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001617
Sean McCullough96b60dd2025-04-30 09:49:10 -07001618 // Transition to processing LLM response state
1619 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1620
Sean McCullough885a16a2025-04-30 02:49:25 +00001621 return resp, nil
1622}
1623
1624// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001625func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1626 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001627 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001628 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001629
Sean McCullough96b60dd2025-04-30 09:49:10 -07001630 // Transition to checking for cancellation state
1631 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1632
Sean McCullough885a16a2025-04-30 02:49:25 +00001633 // Check if the operation was cancelled by the user
1634 select {
1635 case <-ctx.Done():
1636 // Don't actually run any of the tools, but rather build a response
1637 // for each tool_use message letting the LLM know that user canceled it.
1638 var err error
1639 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001640 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001641 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001642 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001643 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001644 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001645 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001646 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001647 // Transition to running tool state
1648 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1649
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001650 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001651 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001652 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001653
1654 // Execute the tools
1655 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001656 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001657 if ctx.Err() != nil { // e.g. the user canceled the operation
1658 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001659 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001660 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001661 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001662 a.pushToOutbox(ctx, errorMessage(err))
1663 }
1664 }
1665
1666 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001667 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001668 autoqualityMessages := a.processGitChanges(ctx)
1669
1670 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001671 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001672 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001673 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001674 return false, nil
1675 }
1676
1677 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001678 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1679 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001680}
1681
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001682// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001683func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001684 // Check for git commits
1685 _, err := a.handleGitCommits(ctx)
1686 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001687 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001688 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001689 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001690 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001691}
1692
1693// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1694// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001695func (a *Agent) processGitChanges(ctx context.Context) []string {
1696 // Check for git commits after tool execution
1697 newCommits, err := a.handleGitCommits(ctx)
1698 if err != nil {
1699 // Just log the error, don't stop execution
1700 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1701 return nil
1702 }
1703
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001704 // Run mechanical checks if there was exactly one new commit.
1705 if len(newCommits) != 1 {
1706 return nil
1707 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001708 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001709 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1710 msg := a.codereview.RunMechanicalChecks(ctx)
1711 if msg != "" {
1712 a.pushToOutbox(ctx, AgentMessage{
1713 Type: AutoMessageType,
1714 Content: msg,
1715 Timestamp: time.Now(),
1716 })
1717 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001718 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001719
1720 return autoqualityMessages
1721}
1722
1723// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001724func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001725 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001726 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001727 msgs, err := a.GatherMessages(ctx, false)
1728 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001729 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001730 return false, nil
1731 }
1732
1733 // Inject any auto-generated messages from quality checks
1734 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001735 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001736 }
1737
1738 // Handle cancellation by appending a message about it
1739 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001740 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001741 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001742 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001743 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1744 } else if err := a.convo.OverBudget(); err != nil {
1745 // Handle budget issues by appending a message about it
1746 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 -07001747 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001748 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1749 }
1750
1751 // Combine tool results with user messages
1752 results = append(results, msgs...)
1753
1754 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001755 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001756 resp, err := a.convo.SendMessage(llm.Message{
1757 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001758 Content: results,
1759 })
1760 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001761 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001762 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1763 return true, nil // Return true to continue the conversation, but with no response
1764 }
1765
Sean McCullough96b60dd2025-04-30 09:49:10 -07001766 // Transition back to processing LLM response
1767 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1768
Sean McCullough885a16a2025-04-30 02:49:25 +00001769 if cancelled {
1770 return false, nil
1771 }
1772
1773 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001774}
1775
1776func (a *Agent) overBudget(ctx context.Context) error {
1777 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001778 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001779 m := budgetMessage(err)
1780 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001781 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001782 a.convo.ResetBudget(a.originalBudget)
1783 return err
1784 }
1785 return nil
1786}
1787
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001788func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001789 // Collect all text content
1790 var allText strings.Builder
1791 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001792 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001793 if allText.Len() > 0 {
1794 allText.WriteString("\n\n")
1795 }
1796 allText.WriteString(content.Text)
1797 }
1798 }
1799 return allText.String()
1800}
1801
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001802func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001803 a.mu.Lock()
1804 defer a.mu.Unlock()
1805 return a.convo.CumulativeUsage()
1806}
1807
Earl Lee2e463fb2025-04-17 11:22:22 -07001808// Diff returns a unified diff of changes made since the agent was instantiated.
1809func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001810 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001811 return "", fmt.Errorf("no initial commit reference available")
1812 }
1813
1814 // Find the repository root
1815 ctx := context.Background()
1816
1817 // If a specific commit hash is provided, show just that commit's changes
1818 if commit != nil && *commit != "" {
1819 // Validate that the commit looks like a valid git SHA
1820 if !isValidGitSHA(*commit) {
1821 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1822 }
1823
1824 // Get the diff for just this commit
1825 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1826 cmd.Dir = a.repoRoot
1827 output, err := cmd.CombinedOutput()
1828 if err != nil {
1829 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1830 }
1831 return string(output), nil
1832 }
1833
1834 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001835 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001836 cmd.Dir = a.repoRoot
1837 output, err := cmd.CombinedOutput()
1838 if err != nil {
1839 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1840 }
1841
1842 return string(output), nil
1843}
1844
Philip Zeyliger49edc922025-05-14 09:45:45 -07001845// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1846// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1847func (a *Agent) SketchGitBaseRef() string {
1848 if a.IsInContainer() {
1849 return "sketch-base"
1850 } else {
1851 return "sketch-base-" + a.SessionID()
1852 }
1853}
1854
1855// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1856func (a *Agent) SketchGitBase() string {
1857 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1858 cmd.Dir = a.repoRoot
1859 output, err := cmd.CombinedOutput()
1860 if err != nil {
1861 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1862 return "HEAD"
1863 }
1864 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001865}
1866
Pokey Rule7a113622025-05-12 10:58:45 +01001867// removeGitHooks removes the Git hooks directory from the repository
1868func removeGitHooks(_ context.Context, repoPath string) error {
1869 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1870
1871 // Check if hooks directory exists
1872 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1873 // Directory doesn't exist, nothing to do
1874 return nil
1875 }
1876
1877 // Remove the hooks directory
1878 err := os.RemoveAll(hooksDir)
1879 if err != nil {
1880 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1881 }
1882
1883 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001884 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001885 if err != nil {
1886 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1887 }
1888
1889 return nil
1890}
1891
Philip Zeyligerf2872992025-05-22 10:35:28 -07001892func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001893 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001894 for _, msg := range msgs {
1895 a.pushToOutbox(ctx, msg)
1896 }
1897 return commits, error
1898}
1899
Earl Lee2e463fb2025-04-17 11:22:22 -07001900// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001901// under docker, new HEADs are pushed to a branch according to the slug.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001902func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001903 ags.mu.Lock()
1904 defer ags.mu.Unlock()
1905
1906 msgs := []AgentMessage{}
1907 if repoRoot == "" {
1908 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001909 }
1910
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001911 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07001912 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001913 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001914 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001915 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001916 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001917 }
1918 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001919 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07001920 }()
1921
Philip Zeyliger64f60462025-06-16 13:57:10 -07001922 // Compute diff stats from baseRef to HEAD when HEAD changes
1923 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
1924 // Log error but don't fail the entire operation
1925 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
1926 } else {
1927 // Set diff stats directly since we already hold the mutex
1928 ags.linesAdded = added
1929 ags.linesRemoved = removed
1930 }
1931
Earl Lee2e463fb2025-04-17 11:22:22 -07001932 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1933 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1934 // to the last 100 commits.
1935 var commits []*GitCommit
1936
1937 // Get commits since the initial commit
1938 // Format: <hash>\0<subject>\0<body>\0
1939 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1940 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001941 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 -07001942 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001943 output, err := cmd.Output()
1944 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001945 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001946 }
1947
1948 // Parse git log output and filter out already seen commits
1949 parsedCommits := parseGitLog(string(output))
1950
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001951 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001952
1953 // Filter out commits we've already seen
1954 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001955 if commit.Hash == sketch {
1956 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07001957 }
1958
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001959 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
1960 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07001961 continue
1962 }
1963
1964 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001965 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001966
1967 // Add to our list of new commits
1968 commits = append(commits, &commit)
1969 }
1970
Philip Zeyligerf2872992025-05-22 10:35:28 -07001971 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001972 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001973 // 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 -07001974 sketchCommit = &GitCommit{}
1975 sketchCommit.Hash = sketch
1976 sketchCommit.Subject = "unknown"
1977 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07001978 }
1979
Earl Lee2e463fb2025-04-17 11:22:22 -07001980 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1981 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1982 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001983
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001984 // 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 +00001985 var out []byte
1986 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001987 originalRetryNumber := ags.retryNumber
1988 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001989 for retries := range 10 {
1990 if retries > 0 {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001991 ags.IncrementRetryNumber()
Philip Zeyliger113e2052025-05-09 21:59:40 +00001992 }
1993
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001994 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001995 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001996 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001997 out, err = cmd.CombinedOutput()
1998
1999 if err == nil {
2000 // Success! Break out of the retry loop
2001 break
2002 }
2003
2004 // Check if this is the "refusing to update checked out branch" error
2005 if !strings.Contains(string(out), "refusing to update checked out branch") {
2006 // This is a different error, so don't retry
2007 break
2008 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002009 }
2010
2011 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002012 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002013 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002014 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002015 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002016 if ags.retryNumber != originalRetryNumber {
2017 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002018 msgs = append(msgs, AgentMessage{
2019 Type: AutoMessageType,
2020 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002021 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 +00002022 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002023 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002024 }
2025 }
2026
2027 // If we found new commits, create a message
2028 if len(commits) > 0 {
2029 msg := AgentMessage{
2030 Type: CommitMessageType,
2031 Timestamp: time.Now(),
2032 Commits: commits,
2033 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002034 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002035 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002036 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002037}
2038
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002039func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002040 return strings.Map(func(r rune) rune {
2041 // lowercase
2042 if r >= 'A' && r <= 'Z' {
2043 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002044 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002045 // replace spaces with dashes
2046 if r == ' ' {
2047 return '-'
2048 }
2049 // allow alphanumerics and dashes
2050 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2051 return r
2052 }
2053 return -1
2054 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002055}
2056
2057// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2058// and returns an array of GitCommit structs.
2059func parseGitLog(output string) []GitCommit {
2060 var commits []GitCommit
2061
2062 // No output means no commits
2063 if len(output) == 0 {
2064 return commits
2065 }
2066
2067 // Split by NULL byte
2068 parts := strings.Split(output, "\x00")
2069
2070 // Process in triplets (hash, subject, body)
2071 for i := 0; i < len(parts); i++ {
2072 // Skip empty parts
2073 if parts[i] == "" {
2074 continue
2075 }
2076
2077 // This should be a hash
2078 hash := strings.TrimSpace(parts[i])
2079
2080 // Make sure we have at least a subject part available
2081 if i+1 >= len(parts) {
2082 break // No more parts available
2083 }
2084
2085 // Get the subject
2086 subject := strings.TrimSpace(parts[i+1])
2087
2088 // Get the body if available
2089 body := ""
2090 if i+2 < len(parts) {
2091 body = strings.TrimSpace(parts[i+2])
2092 }
2093
2094 // Skip to the next triplet
2095 i += 2
2096
2097 commits = append(commits, GitCommit{
2098 Hash: hash,
2099 Subject: subject,
2100 Body: body,
2101 })
2102 }
2103
2104 return commits
2105}
2106
2107func repoRoot(ctx context.Context, dir string) (string, error) {
2108 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2109 stderr := new(strings.Builder)
2110 cmd.Stderr = stderr
2111 cmd.Dir = dir
2112 out, err := cmd.Output()
2113 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002114 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002115 }
2116 return strings.TrimSpace(string(out)), nil
2117}
2118
2119func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2120 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2121 stderr := new(strings.Builder)
2122 cmd.Stderr = stderr
2123 cmd.Dir = dir
2124 out, err := cmd.Output()
2125 if err != nil {
2126 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2127 }
2128 // TODO: validate that out is valid hex
2129 return strings.TrimSpace(string(out)), nil
2130}
2131
2132// isValidGitSHA validates if a string looks like a valid git SHA hash.
2133// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2134func isValidGitSHA(sha string) bool {
2135 // Git SHA must be a hexadecimal string with at least 4 characters
2136 if len(sha) < 4 || len(sha) > 40 {
2137 return false
2138 }
2139
2140 // Check if the string only contains hexadecimal characters
2141 for _, char := range sha {
2142 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2143 return false
2144 }
2145 }
2146
2147 return true
2148}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002149
Philip Zeyliger64f60462025-06-16 13:57:10 -07002150// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2151func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2152 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2153 cmd.Dir = repoRoot
2154 out, err := cmd.Output()
2155 if err != nil {
2156 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2157 }
2158
2159 var totalAdded, totalRemoved int
2160 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2161 for _, line := range lines {
2162 if line == "" {
2163 continue
2164 }
2165 parts := strings.Fields(line)
2166 if len(parts) < 2 {
2167 continue
2168 }
2169 // Format: <added>\t<removed>\t<filename>
2170 if added, err := strconv.Atoi(parts[0]); err == nil {
2171 totalAdded += added
2172 }
2173 if removed, err := strconv.Atoi(parts[1]); err == nil {
2174 totalRemoved += removed
2175 }
2176 }
2177
2178 return totalAdded, totalRemoved, nil
2179}
2180
Philip Zeyligerd1402952025-04-23 03:54:37 +00002181// getGitOrigin returns the URL of the git remote 'origin' if it exists
2182func getGitOrigin(ctx context.Context, dir string) string {
2183 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2184 cmd.Dir = dir
2185 stderr := new(strings.Builder)
2186 cmd.Stderr = stderr
2187 out, err := cmd.Output()
2188 if err != nil {
2189 return ""
2190 }
2191 return strings.TrimSpace(string(out))
2192}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002193
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002194// systemPromptData contains the data used to render the system prompt template
2195type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002196 ClientGOOS string
2197 ClientGOARCH string
2198 WorkingDir string
2199 RepoRoot string
2200 InitialCommit string
2201 Codebase *onstart.Codebase
2202 UseSketchWIP bool
2203 Branch string
2204 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002205}
2206
2207// renderSystemPrompt renders the system prompt template.
2208func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002209 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002210 ClientGOOS: a.config.ClientGOOS,
2211 ClientGOARCH: a.config.ClientGOARCH,
2212 WorkingDir: a.workingDir,
2213 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002214 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002215 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002216 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002217 }
David Crawshawc886ac52025-06-13 23:40:03 +00002218 now := time.Now()
2219 if now.Month() == time.September && now.Day() == 19 {
2220 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2221 }
2222
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002223 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2224 if err != nil {
2225 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2226 }
2227 buf := new(strings.Builder)
2228 err = tmpl.Execute(buf, data)
2229 if err != nil {
2230 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2231 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002232 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002233 return buf.String()
2234}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002235
2236// StateTransitionIterator provides an iterator over state transitions.
2237type StateTransitionIterator interface {
2238 // Next blocks until a new state transition is available or context is done.
2239 // Returns nil if the context is cancelled.
2240 Next() *StateTransition
2241 // Close removes the listener and cleans up resources.
2242 Close()
2243}
2244
2245// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2246type StateTransitionIteratorImpl struct {
2247 agent *Agent
2248 ctx context.Context
2249 ch chan StateTransition
2250 unsubscribe func()
2251}
2252
2253// Next blocks until a new state transition is available or the context is cancelled.
2254func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2255 select {
2256 case <-s.ctx.Done():
2257 return nil
2258 case transition, ok := <-s.ch:
2259 if !ok {
2260 return nil
2261 }
2262 transitionCopy := transition
2263 return &transitionCopy
2264 }
2265}
2266
2267// Close removes the listener and cleans up resources.
2268func (s *StateTransitionIteratorImpl) Close() {
2269 if s.unsubscribe != nil {
2270 s.unsubscribe()
2271 s.unsubscribe = nil
2272 }
2273}
2274
2275// NewStateTransitionIterator returns an iterator that receives state transitions.
2276func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2277 a.mu.Lock()
2278 defer a.mu.Unlock()
2279
2280 // Create channel to receive state transitions
2281 ch := make(chan StateTransition, 10)
2282
2283 // Add a listener to the state machine
2284 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2285
2286 return &StateTransitionIteratorImpl{
2287 agent: a,
2288 ctx: ctx,
2289 ch: ch,
2290 unsubscribe: unsubscribe,
2291 }
2292}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002293
2294// setupGitHooks creates or updates git hooks in the specified working directory.
2295func setupGitHooks(workingDir string) error {
2296 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2297
2298 _, err := os.Stat(hooksDir)
2299 if os.IsNotExist(err) {
2300 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2301 }
2302 if err != nil {
2303 return fmt.Errorf("error checking git hooks directory: %w", err)
2304 }
2305
2306 // Define the post-commit hook content
2307 postCommitHook := `#!/bin/bash
2308echo "<post_commit_hook>"
2309echo "Please review this commit message and fix it if it is incorrect."
2310echo "This hook only echos the commit message; it does not modify it."
2311echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2312echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002313PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002314echo "</last_commit_message>"
2315echo "</post_commit_hook>"
2316`
2317
2318 // Define the prepare-commit-msg hook content
2319 prepareCommitMsgHook := `#!/bin/bash
2320# Add Co-Authored-By and Change-ID trailers to commit messages
2321# Check if these trailers already exist before adding them
2322
2323commit_file="$1"
2324COMMIT_SOURCE="$2"
2325
2326# Skip for merges, squashes, or when using a commit template
2327if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2328 [ "$COMMIT_SOURCE" = "squash" ]; then
2329 exit 0
2330fi
2331
2332commit_msg=$(cat "$commit_file")
2333
2334needs_co_author=true
2335needs_change_id=true
2336
2337# Check if commit message already has Co-Authored-By trailer
2338if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2339 needs_co_author=false
2340fi
2341
2342# Check if commit message already has Change-ID trailer
2343if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2344 needs_change_id=false
2345fi
2346
2347# Only modify if at least one trailer needs to be added
2348if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002349 # Ensure there's a proper blank line before trailers
2350 if [ -s "$commit_file" ]; then
2351 # Check if file ends with newline by reading last character
2352 last_char=$(tail -c 1 "$commit_file")
2353
2354 if [ "$last_char" != "" ]; then
2355 # File doesn't end with newline - add two newlines (complete line + blank line)
2356 echo "" >> "$commit_file"
2357 echo "" >> "$commit_file"
2358 else
2359 # File ends with newline - check if we already have a blank line
2360 last_line=$(tail -1 "$commit_file")
2361 if [ -n "$last_line" ]; then
2362 # Last line has content - add one newline for blank line
2363 echo "" >> "$commit_file"
2364 fi
2365 # If last line is empty, we already have a blank line - don't add anything
2366 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002367 fi
2368
2369 # Add trailers if needed
2370 if [ "$needs_co_author" = true ]; then
2371 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2372 fi
2373
2374 if [ "$needs_change_id" = true ]; then
2375 change_id=$(openssl rand -hex 8)
2376 echo "Change-ID: s${change_id}k" >> "$commit_file"
2377 fi
2378fi
2379`
2380
2381 // Update or create the post-commit hook
2382 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2383 if err != nil {
2384 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2385 }
2386
2387 // Update or create the prepare-commit-msg hook
2388 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2389 if err != nil {
2390 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2391 }
2392
2393 return nil
2394}
2395
2396// updateOrCreateHook creates a new hook file or updates an existing one
2397// by appending the new content if it doesn't already contain it.
2398func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2399 // Check if the hook already exists
2400 buf, err := os.ReadFile(hookPath)
2401 if os.IsNotExist(err) {
2402 // Hook doesn't exist, create it
2403 err = os.WriteFile(hookPath, []byte(content), 0o755)
2404 if err != nil {
2405 return fmt.Errorf("failed to create hook: %w", err)
2406 }
2407 return nil
2408 }
2409 if err != nil {
2410 return fmt.Errorf("error reading existing hook: %w", err)
2411 }
2412
2413 // Hook exists, check if our content is already in it by looking for a distinctive line
2414 code := string(buf)
2415 if strings.Contains(code, distinctiveLine) {
2416 // Already contains our content, nothing to do
2417 return nil
2418 }
2419
2420 // Append our content to the existing hook
2421 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2422 if err != nil {
2423 return fmt.Errorf("failed to open hook for appending: %w", err)
2424 }
2425 defer f.Close()
2426
2427 // Ensure there's a newline at the end of the existing content if needed
2428 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2429 _, err = f.WriteString("\n")
2430 if err != nil {
2431 return fmt.Errorf("failed to add newline to hook: %w", err)
2432 }
2433 }
2434
2435 // Add a separator before our content
2436 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2437 if err != nil {
2438 return fmt.Errorf("failed to append to hook: %w", err)
2439 }
2440
2441 return nil
2442}
Sean McCullough138ec242025-06-02 22:42:06 +00002443
2444// GetPortMonitor returns the port monitor instance for accessing port events
2445func (a *Agent) GetPortMonitor() *PortMonitor {
2446 return a.portMonitor
2447}
Philip Zeyliger0113be52025-06-07 23:53:41 +00002448
2449// SkabandAddr returns the skaband address if configured
2450func (a *Agent) SkabandAddr() string {
2451 if a.config.SkabandClient != nil {
2452 return a.config.SkabandClient.Addr()
2453 }
2454 return ""
2455}