blob: 628270565c5bf8f6f852841668ed54cdc93228c1 [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 Zeyliger75bd37d2025-05-22 18:49:14 +0000118 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700119 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000120
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000121 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
122 OutstandingLLMCallCount() int
123
124 // OutstandingToolCalls returns the names of outstanding tool calls.
125 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000126 OutsideOS() string
127 OutsideHostname() string
128 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000129 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000130 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
131 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700132
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700133 // IsInContainer returns true if the agent is running in a container
134 IsInContainer() bool
135 // FirstMessageIndex returns the index of the first message in the current conversation
136 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700137
138 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700139 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
140 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700141
142 // CompactConversation compacts the current conversation by generating a summary
143 // and restarting the conversation with that summary as the initial context
144 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000145 // GetPortMonitor returns the port monitor instance for accessing port events
146 GetPortMonitor() *PortMonitor
Philip Zeyliger0113be52025-06-07 23:53:41 +0000147 // SkabandAddr returns the skaband address if configured
148 SkabandAddr() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700149}
150
151type CodingAgentMessageType string
152
153const (
154 UserMessageType CodingAgentMessageType = "user"
155 AgentMessageType CodingAgentMessageType = "agent"
156 ErrorMessageType CodingAgentMessageType = "error"
157 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
158 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700159 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
160 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
161 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700162
163 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
164)
165
166type AgentMessage struct {
167 Type CodingAgentMessageType `json:"type"`
168 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
169 EndOfTurn bool `json:"end_of_turn"`
170
171 Content string `json:"content"`
172 ToolName string `json:"tool_name,omitempty"`
173 ToolInput string `json:"input,omitempty"`
174 ToolResult string `json:"tool_result,omitempty"`
175 ToolError bool `json:"tool_error,omitempty"`
176 ToolCallId string `json:"tool_call_id,omitempty"`
177
178 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
179 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
180
Sean McCulloughd9f13372025-04-21 15:08:49 -0700181 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
182 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
183
Earl Lee2e463fb2025-04-17 11:22:22 -0700184 // Commits is a list of git commits for a commit message
185 Commits []*GitCommit `json:"commits,omitempty"`
186
187 Timestamp time.Time `json:"timestamp"`
188 ConversationID string `json:"conversation_id"`
189 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700190 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700191
192 // Message timing information
193 StartTime *time.Time `json:"start_time,omitempty"`
194 EndTime *time.Time `json:"end_time,omitempty"`
195 Elapsed *time.Duration `json:"elapsed,omitempty"`
196
197 // Turn duration - the time taken for a complete agent turn
198 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
199
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000200 // HideOutput indicates that this message should not be rendered in the UI.
201 // This is useful for subconversations that generate output that shouldn't be shown to the user.
202 HideOutput bool `json:"hide_output,omitempty"`
203
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700204 // TodoContent contains the agent's todo file content when it has changed
205 TodoContent *string `json:"todo_content,omitempty"`
206
Earl Lee2e463fb2025-04-17 11:22:22 -0700207 Idx int `json:"idx"`
208}
209
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000210// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700211func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700212 if convo == nil {
213 m.ConversationID = ""
214 m.ParentConversationID = nil
215 return
216 }
217 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000218 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700219 if convo.Parent != nil {
220 m.ParentConversationID = &convo.Parent.ID
221 }
222}
223
Earl Lee2e463fb2025-04-17 11:22:22 -0700224// GitCommit represents a single git commit for a commit message
225type GitCommit struct {
226 Hash string `json:"hash"` // Full commit hash
227 Subject string `json:"subject"` // Commit subject line
228 Body string `json:"body"` // Full commit message body
229 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
230}
231
232// ToolCall represents a single tool call within an agent message
233type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700234 Name string `json:"name"`
235 Input string `json:"input"`
236 ToolCallId string `json:"tool_call_id"`
237 ResultMessage *AgentMessage `json:"result_message,omitempty"`
238 Args string `json:"args,omitempty"`
239 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700240}
241
242func (a *AgentMessage) Attr() slog.Attr {
243 var attrs []any = []any{
244 slog.String("type", string(a.Type)),
245 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700246 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700247 if a.EndOfTurn {
248 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
249 }
250 if a.Content != "" {
251 attrs = append(attrs, slog.String("content", a.Content))
252 }
253 if a.ToolName != "" {
254 attrs = append(attrs, slog.String("tool_name", a.ToolName))
255 }
256 if a.ToolInput != "" {
257 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
258 }
259 if a.Elapsed != nil {
260 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
261 }
262 if a.TurnDuration != nil {
263 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
264 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700265 if len(a.ToolResult) > 0 {
266 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700267 }
268 if a.ToolError {
269 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
270 }
271 if len(a.ToolCalls) > 0 {
272 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
273 for i, tc := range a.ToolCalls {
274 toolCallAttrs = append(toolCallAttrs, slog.Group(
275 fmt.Sprintf("tool_call_%d", i),
276 slog.String("name", tc.Name),
277 slog.String("input", tc.Input),
278 ))
279 }
280 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
281 }
282 if a.ConversationID != "" {
283 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
284 }
285 if a.ParentConversationID != nil {
286 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
287 }
288 if a.Usage != nil && !a.Usage.IsZero() {
289 attrs = append(attrs, a.Usage.Attr())
290 }
291 // TODO: timestamp, convo ids, idx?
292 return slog.Group("agent_message", attrs...)
293}
294
295func errorMessage(err error) AgentMessage {
296 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
297 if os.Getenv(("DEBUG")) == "1" {
298 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
299 }
300
301 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
302}
303
304func budgetMessage(err error) AgentMessage {
305 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
306}
307
308// ConvoInterface defines the interface for conversation interactions
309type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700310 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700311 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700312 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700313 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700314 SendMessage(message llm.Message) (*llm.Response, error)
315 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700316 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000317 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700318 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700320 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700321}
322
Philip Zeyligerf2872992025-05-22 10:35:28 -0700323// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700324// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700325// any time we notice we need to.
326type AgentGitState struct {
327 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700328 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700329 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000330 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700331 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700332 slug string // Human-readable session identifier
333 retryNumber int // Number to append when branch conflicts occur
Philip Zeyligerf2872992025-05-22 10:35:28 -0700334}
335
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700336func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700337 ags.mu.Lock()
338 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700339 if ags.slug != slug {
340 ags.retryNumber = 0
341 }
342 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700343}
344
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700345func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700346 ags.mu.Lock()
347 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700348 return ags.slug
349}
350
351func (ags *AgentGitState) IncrementRetryNumber() {
352 ags.mu.Lock()
353 defer ags.mu.Unlock()
354 ags.retryNumber++
355}
356
357// HasSeenCommits returns true if any commits have been processed
358func (ags *AgentGitState) HasSeenCommits() bool {
359 ags.mu.Lock()
360 defer ags.mu.Unlock()
361 return len(ags.seenCommits) > 0
362}
363
364func (ags *AgentGitState) RetryNumber() int {
365 ags.mu.Lock()
366 defer ags.mu.Unlock()
367 return ags.retryNumber
368}
369
370func (ags *AgentGitState) BranchName(prefix string) string {
371 ags.mu.Lock()
372 defer ags.mu.Unlock()
373 return ags.branchNameLocked(prefix)
374}
375
376func (ags *AgentGitState) branchNameLocked(prefix string) string {
377 if ags.slug == "" {
378 return ""
379 }
380 if ags.retryNumber == 0 {
381 return prefix + ags.slug
382 }
383 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700384}
385
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000386func (ags *AgentGitState) Upstream() string {
387 ags.mu.Lock()
388 defer ags.mu.Unlock()
389 return ags.upstream
390}
391
Earl Lee2e463fb2025-04-17 11:22:22 -0700392type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700393 convo ConvoInterface
394 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700395 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700396 workingDir string
397 repoRoot string // workingDir may be a subdir of repoRoot
398 url string
399 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000400 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700401 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000402 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700403 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700404 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000405 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700406 // State machine to track agent state
407 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000408 // Outside information
409 outsideHostname string
410 outsideOS string
411 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000412 // URL of the git remote 'origin' if it exists
413 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700414
415 // Time when the current turn started (reset at the beginning of InnerLoop)
416 startOfTurn time.Time
417
418 // Inbox - for messages from the user to the agent.
419 // sent on by UserMessage
420 // . e.g. when user types into the chat textarea
421 // read from by GatherMessages
422 inbox chan string
423
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000424 // protects cancelTurn
425 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700426 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000427 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700428
429 // protects following
430 mu sync.Mutex
431
432 // Stores all messages for this agent
433 history []AgentMessage
434
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700435 // Iterators add themselves here when they're ready to be notified of new messages.
436 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700437
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000438 // Track outstanding LLM call IDs
439 outstandingLLMCalls map[string]struct{}
440
441 // Track outstanding tool calls by ID with their names
442 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000443
444 // Port monitoring
445 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700446}
447
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700448// NewIterator implements CodingAgent.
449func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
450 a.mu.Lock()
451 defer a.mu.Unlock()
452
453 return &MessageIteratorImpl{
454 agent: a,
455 ctx: ctx,
456 nextMessageIdx: nextMessageIdx,
457 ch: make(chan *AgentMessage, 100),
458 }
459}
460
461type MessageIteratorImpl struct {
462 agent *Agent
463 ctx context.Context
464 nextMessageIdx int
465 ch chan *AgentMessage
466 subscribed bool
467}
468
469func (m *MessageIteratorImpl) Close() {
470 m.agent.mu.Lock()
471 defer m.agent.mu.Unlock()
472 // Delete ourselves from the subscribers list
473 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
474 return x == m.ch
475 })
476 close(m.ch)
477}
478
479func (m *MessageIteratorImpl) Next() *AgentMessage {
480 // We avoid subscription at creation to let ourselves catch up to "current state"
481 // before subscribing.
482 if !m.subscribed {
483 m.agent.mu.Lock()
484 if m.nextMessageIdx < len(m.agent.history) {
485 msg := &m.agent.history[m.nextMessageIdx]
486 m.nextMessageIdx++
487 m.agent.mu.Unlock()
488 return msg
489 }
490 // The next message doesn't exist yet, so let's subscribe
491 m.agent.subscribers = append(m.agent.subscribers, m.ch)
492 m.subscribed = true
493 m.agent.mu.Unlock()
494 }
495
496 for {
497 select {
498 case <-m.ctx.Done():
499 m.agent.mu.Lock()
500 // Delete ourselves from the subscribers list
501 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
502 return x == m.ch
503 })
504 m.subscribed = false
505 m.agent.mu.Unlock()
506 return nil
507 case msg, ok := <-m.ch:
508 if !ok {
509 // Close may have been called
510 return nil
511 }
512 if msg.Idx == m.nextMessageIdx {
513 m.nextMessageIdx++
514 return msg
515 }
516 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
517 panic("out of order message")
518 }
519 }
520}
521
Sean McCulloughd9d45812025-04-30 16:53:41 -0700522// Assert that Agent satisfies the CodingAgent interface.
523var _ CodingAgent = &Agent{}
524
525// StateName implements CodingAgent.
526func (a *Agent) CurrentStateName() string {
527 if a.stateMachine == nil {
528 return ""
529 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000530 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700531}
532
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700533// CurrentTodoContent returns the current todo list data as JSON.
534// It returns an empty string if no todos exist.
535func (a *Agent) CurrentTodoContent() string {
536 todoPath := claudetool.TodoFilePath(a.config.SessionID)
537 content, err := os.ReadFile(todoPath)
538 if err != nil {
539 return ""
540 }
541 return string(content)
542}
543
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700544// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
545func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
546 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.
547
548IMPORTANT: 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.
549
550Please create a detailed summary that includes:
551
5521. **User's Request**: What did the user originally ask me to do? What was their goal?
553
5542. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
555
5563. **Key Technical Decisions**: What important technical choices were made during our work and why?
557
5584. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
559
5605. **Next Steps**: What still needs to be done to complete the user's request?
561
5626. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
563
564Focus 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.
565
566Reply with ONLY the summary content - no meta-commentary about creating the summary.`
567
568 userMessage := llm.UserStringMessage(msg)
569 // Use a subconversation with history to get the summary
570 // TODO: We don't have any tools here, so we should have enough tokens
571 // to capture a summary, but we may need to modify the history (e.g., remove
572 // TODO data) to save on some tokens.
573 convo := a.convo.SubConvoWithHistory()
574
575 // Modify the system prompt to provide context about the original task
576 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000577 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 -0700578
579Your 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.
580
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000581Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700582
583 resp, err := convo.SendMessage(userMessage)
584 if err != nil {
585 a.pushToOutbox(ctx, errorMessage(err))
586 return "", err
587 }
588 textContent := collectTextContent(resp)
589
590 // Restore original system prompt (though this subconvo will be discarded)
591 convo.SystemPrompt = originalSystemPrompt
592
593 return textContent, nil
594}
595
596// CompactConversation compacts the current conversation by generating a summary
597// and restarting the conversation with that summary as the initial context
598func (a *Agent) CompactConversation(ctx context.Context) error {
599 summary, err := a.generateConversationSummary(ctx)
600 if err != nil {
601 return fmt.Errorf("failed to generate conversation summary: %w", err)
602 }
603
604 a.mu.Lock()
605
606 // Get usage information before resetting conversation
607 lastUsage := a.convo.LastUsage()
608 contextWindow := a.config.Service.TokenContextWindow()
609 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
610
611 // Reset conversation state but keep all other state (git, working dir, etc.)
612 a.firstMessageIndex = len(a.history)
613 a.convo = a.initConvo()
614
615 a.mu.Unlock()
616
617 // Create informative compaction message with token details
618 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
619 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
620 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
621
622 a.pushToOutbox(ctx, AgentMessage{
623 Type: CompactMessageType,
624 Content: compactionMsg,
625 })
626
627 a.pushToOutbox(ctx, AgentMessage{
628 Type: UserMessageType,
629 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),
630 })
631 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)
632
633 return nil
634}
635
Earl Lee2e463fb2025-04-17 11:22:22 -0700636func (a *Agent) URL() string { return a.url }
637
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000638// BranchName returns the git branch name for the conversation.
639func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700640 return a.gitState.BranchName(a.config.BranchPrefix)
641}
642
643// Slug returns the slug identifier for this conversation.
644func (a *Agent) Slug() string {
645 return a.gitState.Slug()
646}
647
648// IncrementRetryNumber increments the retry number for branch naming conflicts
649func (a *Agent) IncrementRetryNumber() {
650 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000651}
652
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000653// OutstandingLLMCallCount returns the number of outstanding LLM calls.
654func (a *Agent) OutstandingLLMCallCount() int {
655 a.mu.Lock()
656 defer a.mu.Unlock()
657 return len(a.outstandingLLMCalls)
658}
659
660// OutstandingToolCalls returns the names of outstanding tool calls.
661func (a *Agent) OutstandingToolCalls() []string {
662 a.mu.Lock()
663 defer a.mu.Unlock()
664
665 tools := make([]string, 0, len(a.outstandingToolCalls))
666 for _, toolName := range a.outstandingToolCalls {
667 tools = append(tools, toolName)
668 }
669 return tools
670}
671
Earl Lee2e463fb2025-04-17 11:22:22 -0700672// OS returns the operating system of the client.
673func (a *Agent) OS() string {
674 return a.config.ClientGOOS
675}
676
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000677func (a *Agent) SessionID() string {
678 return a.config.SessionID
679}
680
Philip Zeyliger18532b22025-04-23 21:11:46 +0000681// OutsideOS returns the operating system of the outside system.
682func (a *Agent) OutsideOS() string {
683 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000684}
685
Philip Zeyliger18532b22025-04-23 21:11:46 +0000686// OutsideHostname returns the hostname of the outside system.
687func (a *Agent) OutsideHostname() string {
688 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000689}
690
Philip Zeyliger18532b22025-04-23 21:11:46 +0000691// OutsideWorkingDir returns the working directory on the outside system.
692func (a *Agent) OutsideWorkingDir() string {
693 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000694}
695
696// GitOrigin returns the URL of the git remote 'origin' if it exists.
697func (a *Agent) GitOrigin() string {
698 return a.gitOrigin
699}
700
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000701func (a *Agent) OpenBrowser(url string) {
702 if !a.IsInContainer() {
703 browser.Open(url)
704 return
705 }
706 // We're in Docker, need to send a request to the Git server
707 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700708 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000709 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700710 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000711 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700712 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000713 return
714 }
715 defer resp.Body.Close()
716 if resp.StatusCode == http.StatusOK {
717 return
718 }
719 body, _ := io.ReadAll(resp.Body)
720 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
721}
722
Sean McCullough96b60dd2025-04-30 09:49:10 -0700723// CurrentState returns the current state of the agent's state machine.
724func (a *Agent) CurrentState() State {
725 return a.stateMachine.CurrentState()
726}
727
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700728func (a *Agent) IsInContainer() bool {
729 return a.config.InDocker
730}
731
732func (a *Agent) FirstMessageIndex() int {
733 a.mu.Lock()
734 defer a.mu.Unlock()
735 return a.firstMessageIndex
736}
737
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700738// SetSlug sets a human-readable identifier for the conversation.
739func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700740 a.mu.Lock()
741 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700742
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700743 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000744 convo, ok := a.convo.(*conversation.Convo)
745 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700746 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000747 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700748}
749
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000750// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700751func (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 +0000752 // Track the tool call
753 a.mu.Lock()
754 a.outstandingToolCalls[id] = toolName
755 a.mu.Unlock()
756}
757
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700758// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
759// If there's only one element in the array and it's a text type, it returns that text directly.
760// It also processes nested ToolResult arrays recursively.
761func contentToString(contents []llm.Content) string {
762 if len(contents) == 0 {
763 return ""
764 }
765
766 // If there's only one element and it's a text type, return it directly
767 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
768 return contents[0].Text
769 }
770
771 // Otherwise, concatenate all text content
772 var result strings.Builder
773 for _, content := range contents {
774 if content.Type == llm.ContentTypeText {
775 result.WriteString(content.Text)
776 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
777 // Recursively process nested tool results
778 result.WriteString(contentToString(content.ToolResult))
779 }
780 }
781
782 return result.String()
783}
784
Earl Lee2e463fb2025-04-17 11:22:22 -0700785// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700786func (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 +0000787 // Remove the tool call from outstanding calls
788 a.mu.Lock()
789 delete(a.outstandingToolCalls, toolID)
790 a.mu.Unlock()
791
Earl Lee2e463fb2025-04-17 11:22:22 -0700792 m := AgentMessage{
793 Type: ToolUseMessageType,
794 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700795 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700796 ToolError: content.ToolError,
797 ToolName: toolName,
798 ToolInput: string(toolInput),
799 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700800 StartTime: content.ToolUseStartTime,
801 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700802 }
803
804 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700805 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
806 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700807 m.Elapsed = &elapsed
808 }
809
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700810 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700811 a.pushToOutbox(ctx, m)
812}
813
814// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700815func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000816 a.mu.Lock()
817 defer a.mu.Unlock()
818 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700819 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
820}
821
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700822// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700823// that need to be displayed (as well as tool calls that we send along when
824// they're done). (It would be reasonable to also mention tool calls when they're
825// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700826func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000827 // Remove the LLM call from outstanding calls
828 a.mu.Lock()
829 delete(a.outstandingLLMCalls, id)
830 a.mu.Unlock()
831
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700832 if resp == nil {
833 // LLM API call failed
834 m := AgentMessage{
835 Type: ErrorMessageType,
836 Content: "API call failed, type 'continue' to try again",
837 }
838 m.SetConvo(convo)
839 a.pushToOutbox(ctx, m)
840 return
841 }
842
Earl Lee2e463fb2025-04-17 11:22:22 -0700843 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700844 if convo.Parent == nil { // subconvos never end the turn
845 switch resp.StopReason {
846 case llm.StopReasonToolUse:
847 // Check whether any of the tool calls are for tools that should end the turn
848 ToolSearch:
849 for _, part := range resp.Content {
850 if part.Type != llm.ContentTypeToolUse {
851 continue
852 }
Sean McCullough021557a2025-05-05 23:20:53 +0000853 // Find the tool by name
854 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700855 if tool.Name == part.ToolName {
856 endOfTurn = tool.EndsTurn
857 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000858 }
859 }
Sean McCullough021557a2025-05-05 23:20:53 +0000860 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700861 default:
862 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000863 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700864 }
865 m := AgentMessage{
866 Type: AgentMessageType,
867 Content: collectTextContent(resp),
868 EndOfTurn: endOfTurn,
869 Usage: &resp.Usage,
870 StartTime: resp.StartTime,
871 EndTime: resp.EndTime,
872 }
873
874 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700875 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700876 var toolCalls []ToolCall
877 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700878 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700879 toolCalls = append(toolCalls, ToolCall{
880 Name: part.ToolName,
881 Input: string(part.ToolInput),
882 ToolCallId: part.ID,
883 })
884 }
885 }
886 m.ToolCalls = toolCalls
887 }
888
889 // Calculate the elapsed time if both start and end times are set
890 if resp.StartTime != nil && resp.EndTime != nil {
891 elapsed := resp.EndTime.Sub(*resp.StartTime)
892 m.Elapsed = &elapsed
893 }
894
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700895 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700896 a.pushToOutbox(ctx, m)
897}
898
899// WorkingDir implements CodingAgent.
900func (a *Agent) WorkingDir() string {
901 return a.workingDir
902}
903
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000904// RepoRoot returns the git repository root directory.
905func (a *Agent) RepoRoot() string {
906 return a.repoRoot
907}
908
Earl Lee2e463fb2025-04-17 11:22:22 -0700909// MessageCount implements CodingAgent.
910func (a *Agent) MessageCount() int {
911 a.mu.Lock()
912 defer a.mu.Unlock()
913 return len(a.history)
914}
915
916// Messages implements CodingAgent.
917func (a *Agent) Messages(start int, end int) []AgentMessage {
918 a.mu.Lock()
919 defer a.mu.Unlock()
920 return slices.Clone(a.history[start:end])
921}
922
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700923// ShouldCompact checks if the conversation should be compacted based on token usage
924func (a *Agent) ShouldCompact() bool {
925 // Get the threshold from environment variable, default to 0.94 (94%)
926 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
927 // and a little bit of buffer.)
928 thresholdRatio := 0.94
929 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
930 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
931 thresholdRatio = parsed
932 }
933 }
934
935 // Get the most recent usage to check current context size
936 lastUsage := a.convo.LastUsage()
937
938 if lastUsage.InputTokens == 0 {
939 // No API calls made yet
940 return false
941 }
942
943 // Calculate the current context size from the last API call
944 // This includes all tokens that were part of the input context:
945 // - Input tokens (user messages, system prompt, conversation history)
946 // - Cache read tokens (cached parts of the context)
947 // - Cache creation tokens (new parts being cached)
948 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
949
950 // Get the service's token context window
951 service := a.config.Service
952 contextWindow := service.TokenContextWindow()
953
954 // Calculate threshold
955 threshold := uint64(float64(contextWindow) * thresholdRatio)
956
957 // Check if we've exceeded the threshold
958 return currentContextSize >= threshold
959}
960
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700961func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700962 return a.originalBudget
963}
964
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000965// Upstream returns the upstream branch for git work
966func (a *Agent) Upstream() string {
967 return a.gitState.Upstream()
968}
969
Earl Lee2e463fb2025-04-17 11:22:22 -0700970// AgentConfig contains configuration for creating a new Agent.
971type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000972 Context context.Context
973 Service llm.Service
974 Budget conversation.Budget
975 GitUsername string
976 GitEmail string
977 SessionID string
978 ClientGOOS string
979 ClientGOARCH string
980 InDocker bool
981 OneShot bool
982 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000983 // Outside information
984 OutsideHostname string
985 OutsideOS string
986 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700987
988 // Outtie's HTTP to, e.g., open a browser
989 OutsideHTTP string
990 // Outtie's Git server
991 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000992 // Upstream branch for git work
993 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700994 // Commit to checkout from Outtie
995 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000996 // Prefix for git branches created by sketch
997 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -0700998 // LinkToGitHub enables GitHub branch linking in UI
999 LinkToGitHub bool
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001000 // Skaband client for session history (optional)
1001 SkabandClient *skabandclient.SkabandClient
Earl Lee2e463fb2025-04-17 11:22:22 -07001002}
1003
1004// NewAgent creates a new Agent.
1005// It is not usable until Init() is called.
1006func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001007 // Set default branch prefix if not specified
1008 if config.BranchPrefix == "" {
1009 config.BranchPrefix = "sketch/"
1010 }
1011
Earl Lee2e463fb2025-04-17 11:22:22 -07001012 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001013 config: config,
1014 ready: make(chan struct{}),
1015 inbox: make(chan string, 100),
1016 subscribers: make([]chan *AgentMessage, 0),
1017 startedAt: time.Now(),
1018 originalBudget: config.Budget,
1019 gitState: AgentGitState{
1020 seenCommits: make(map[string]bool),
1021 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001022 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001023 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001024 outsideHostname: config.OutsideHostname,
1025 outsideOS: config.OutsideOS,
1026 outsideWorkingDir: config.OutsideWorkingDir,
1027 outstandingLLMCalls: make(map[string]struct{}),
1028 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001029 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001030 workingDir: config.WorkingDir,
1031 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +00001032 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001033 }
1034 return agent
1035}
1036
1037type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001038 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001039
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001040 InDocker bool
1041 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001042}
1043
1044func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001045 if a.convo != nil {
1046 return fmt.Errorf("Agent.Init: already initialized")
1047 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001048 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001049 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001050
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001051 if !ini.NoGit {
1052 // Capture the original origin before we potentially replace it below
1053 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
1054 }
1055
Philip Zeyliger222bf412025-06-04 16:42:58 +00001056 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001057 if a.gitState.gitRemoteAddr != "" {
1058 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001059
1060 // Remove existing origin remote if it exists
1061 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001062 cmd.Dir = a.workingDir
1063 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001064 // Ignore error if origin doesn't exist
1065 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001066 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001067
1068 // Add the new remote as origin
1069 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001070 cmd.Dir = a.workingDir
1071 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001072 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001073 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001074
Philip Zeyligerf2872992025-05-22 10:35:28 -07001075 }
1076
1077 // If a commit was specified, we fetch and reset to it.
1078 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001079 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1080
Earl Lee2e463fb2025-04-17 11:22:22 -07001081 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001082 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001083 if out, err := cmd.CombinedOutput(); err != nil {
1084 return fmt.Errorf("git stash: %s: %v", out, err)
1085 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001086 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001087 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001088 if out, err := cmd.CombinedOutput(); err != nil {
1089 return fmt.Errorf("git fetch: %s: %w", out, err)
1090 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001091 // The -B resets the branch if it already exists (or creates it if it doesn't)
1092 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001093 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001094 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1095 // Remove git hooks if they exist and retry
1096 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001097 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001098 if _, statErr := os.Stat(hookPath); statErr == nil {
1099 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1100 slog.String("error", err.Error()),
1101 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001102 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001103 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1104 }
1105
1106 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001107 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1108 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001109 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001110 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 +01001111 }
1112 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001113 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001114 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001115 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001116 } else if a.IsInContainer() {
1117 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1118 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1119 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1120 cmd.Dir = a.workingDir
1121 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1122 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1123 }
1124 } else {
1125 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001126 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001127
1128 if ini.HostAddr != "" {
1129 a.url = "http://" + ini.HostAddr
1130 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001131
1132 if !ini.NoGit {
1133 repoRoot, err := repoRoot(ctx, a.workingDir)
1134 if err != nil {
1135 return fmt.Errorf("repoRoot: %w", err)
1136 }
1137 a.repoRoot = repoRoot
1138
Earl Lee2e463fb2025-04-17 11:22:22 -07001139 if err != nil {
1140 return fmt.Errorf("resolveRef: %w", err)
1141 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001142
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001143 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001144 if err := setupGitHooks(a.repoRoot); err != nil {
1145 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1146 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001147 }
1148
Philip Zeyliger49edc922025-05-14 09:45:45 -07001149 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1150 cmd.Dir = repoRoot
1151 if out, err := cmd.CombinedOutput(); err != nil {
1152 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1153 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001154
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001155 slog.Info("running codebase analysis")
1156 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1157 if err != nil {
1158 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001159 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001160 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001161
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001162 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001163 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001164 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001165 }
1166 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001167
Earl Lee2e463fb2025-04-17 11:22:22 -07001168 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001169 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001170 a.convo = a.initConvo()
1171 close(a.ready)
1172 return nil
1173}
1174
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001175//go:embed agent_system_prompt.txt
1176var agentSystemPrompt string
1177
Earl Lee2e463fb2025-04-17 11:22:22 -07001178// initConvo initializes the conversation.
1179// It must not be called until all agent fields are initialized,
1180// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001181func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001182 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001183 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001184 convo.PromptCaching = true
1185 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001186 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001187 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001188
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001189 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1190 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001191 if a.gitState.Slug() != "" {
1192 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001193 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001194 willCommit, err := bashkit.WillRunGitCommit(command)
1195 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001196 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001197 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001198 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001199 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001200 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001201 return nil
1202 }
1203
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001204 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001205
Earl Lee2e463fb2025-04-17 11:22:22 -07001206 // Register all tools with the conversation
1207 // When adding, removing, or modifying tools here, double-check that the termui tool display
1208 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001209
1210 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001211 _, supportsScreenshots := a.config.Service.(*ant.Service)
1212 var bTools []*llm.Tool
1213 var browserCleanup func()
1214
1215 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1216 // Add cleanup function to context cancel
1217 go func() {
1218 <-a.config.Context.Done()
1219 browserCleanup()
1220 }()
1221 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001222
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001223 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001224 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001225 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001226 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001227 }
1228
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001229 // One-shot mode is non-interactive, multiple choice requires human response
1230 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001231 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001232 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001233
1234 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001235
1236 // Add session history tools if skaband client is available
1237 if a.config.SkabandClient != nil {
1238 sessionHistoryTools := claudetool.CreateSessionHistoryTools(a.config.SkabandClient, a.config.SessionID, a.gitOrigin)
1239 convo.Tools = append(convo.Tools, sessionHistoryTools...)
1240 }
1241
Earl Lee2e463fb2025-04-17 11:22:22 -07001242 convo.Listener = a
1243 return convo
1244}
1245
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001246var multipleChoiceTool = &llm.Tool{
1247 Name: "multiplechoice",
1248 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.",
1249 EndsTurn: true,
1250 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001251 "type": "object",
1252 "description": "The question and a list of answers you would expect the user to choose from.",
1253 "properties": {
1254 "question": {
1255 "type": "string",
1256 "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?'"
1257 },
1258 "responseOptions": {
1259 "type": "array",
1260 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1261 "items": {
1262 "type": "object",
1263 "properties": {
1264 "caption": {
1265 "type": "string",
1266 "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'"
1267 },
1268 "responseText": {
1269 "type": "string",
1270 "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'"
1271 }
1272 },
1273 "required": ["caption", "responseText"]
1274 }
1275 }
1276 },
1277 "required": ["question", "responseOptions"]
1278}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001279 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1280 // The Run logic for "multiplechoice" tool is a no-op on the server.
1281 // The UI will present a list of options for the user to select from,
1282 // and that's it as far as "executing" the tool_use goes.
1283 // When the user *does* select one of the presented options, that
1284 // responseText gets sent as a chat message on behalf of the user.
1285 return llm.TextContent("end your turn and wait for the user to respond"), nil
1286 },
Sean McCullough485afc62025-04-28 14:28:39 -07001287}
1288
1289type MultipleChoiceOption struct {
1290 Caption string `json:"caption"`
1291 ResponseText string `json:"responseText"`
1292}
1293
1294type MultipleChoiceParams struct {
1295 Question string `json:"question"`
1296 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1297}
1298
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001299// branchExists reports whether branchName exists, either locally or in well-known remotes.
1300func branchExists(dir, branchName string) bool {
1301 refs := []string{
1302 "refs/heads/",
1303 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001304 }
1305 for _, ref := range refs {
1306 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1307 cmd.Dir = dir
1308 if cmd.Run() == nil { // exit code 0 means branch exists
1309 return true
1310 }
1311 }
1312 return false
1313}
1314
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001315func (a *Agent) setSlugTool() *llm.Tool {
1316 return &llm.Tool{
1317 Name: "set-slug",
1318 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001319 InputSchema: json.RawMessage(`{
1320 "type": "object",
1321 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001322 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001323 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001324 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001325 }
1326 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001327 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001328}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001329 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001330 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001331 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001332 }
1333 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001334 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001335 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001336 // Prevent slug changes if there have been git changes
1337 // This lets the agent change its mind about a good slug,
1338 // while ensuring that once a branch has been pushed, it remains stable.
1339 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1340 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001341 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001342 if params.Slug == "" {
1343 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001344 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001345 slug := cleanSlugName(params.Slug)
1346 if slug == "" {
1347 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1348 }
1349 a.SetSlug(slug)
1350 // TODO: do this by a call to outie, rather than semi-guessing from innie
1351 if branchExists(a.workingDir, a.BranchName()) {
1352 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1353 }
1354 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001355 },
1356 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001357}
1358
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001359func (a *Agent) commitMessageStyleTool() *llm.Tool {
1360 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 +00001361 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001362 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001363 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001364 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001365 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001366 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1367 if err != nil {
1368 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1369 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001370 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001371 },
1372 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001373 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001374}
1375
1376func (a *Agent) Ready() <-chan struct{} {
1377 return a.ready
1378}
1379
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001380// BranchPrefix returns the configured branch prefix
1381func (a *Agent) BranchPrefix() string {
1382 return a.config.BranchPrefix
1383}
1384
philip.zeyliger6d3de482025-06-10 19:38:14 -07001385// LinkToGitHub returns whether GitHub branch linking is enabled
1386func (a *Agent) LinkToGitHub() bool {
1387 return a.config.LinkToGitHub
1388}
1389
Earl Lee2e463fb2025-04-17 11:22:22 -07001390func (a *Agent) UserMessage(ctx context.Context, msg string) {
1391 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1392 a.inbox <- msg
1393}
1394
Earl Lee2e463fb2025-04-17 11:22:22 -07001395func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1396 return a.convo.CancelToolUse(toolUseID, cause)
1397}
1398
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001399func (a *Agent) CancelTurn(cause error) {
1400 a.cancelTurnMu.Lock()
1401 defer a.cancelTurnMu.Unlock()
1402 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001403 // Force state transition to cancelled state
1404 ctx := a.config.Context
1405 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001406 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001407 }
1408}
1409
1410func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001411 // Start port monitoring when the agent loop begins
1412 // Only monitor ports when running in a container
1413 if a.IsInContainer() {
1414 a.portMonitor.Start(ctxOuter)
1415 }
1416
Earl Lee2e463fb2025-04-17 11:22:22 -07001417 for {
1418 select {
1419 case <-ctxOuter.Done():
1420 return
1421 default:
1422 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001423 a.cancelTurnMu.Lock()
1424 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001425 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001426 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001427 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001428 a.cancelTurn = cancel
1429 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001430 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1431 if err != nil {
1432 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1433 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001434 cancel(nil)
1435 }
1436 }
1437}
1438
1439func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1440 if m.Timestamp.IsZero() {
1441 m.Timestamp = time.Now()
1442 }
1443
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001444 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1445 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1446 m.Content = m.ToolResult
1447 }
1448
Earl Lee2e463fb2025-04-17 11:22:22 -07001449 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1450 if m.EndOfTurn && m.Type == AgentMessageType {
1451 turnDuration := time.Since(a.startOfTurn)
1452 m.TurnDuration = &turnDuration
1453 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1454 }
1455
Earl Lee2e463fb2025-04-17 11:22:22 -07001456 a.mu.Lock()
1457 defer a.mu.Unlock()
1458 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001459 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001460 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001461
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001462 // Notify all subscribers
1463 for _, ch := range a.subscribers {
1464 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001465 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001466}
1467
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001468func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1469 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001470 if block {
1471 select {
1472 case <-ctx.Done():
1473 return m, ctx.Err()
1474 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001475 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001476 }
1477 }
1478 for {
1479 select {
1480 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001481 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001482 default:
1483 return m, nil
1484 }
1485 }
1486}
1487
Sean McCullough885a16a2025-04-30 02:49:25 +00001488// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001489func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001490 // Reset the start of turn time
1491 a.startOfTurn = time.Now()
1492
Sean McCullough96b60dd2025-04-30 09:49:10 -07001493 // Transition to waiting for user input state
1494 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1495
Sean McCullough885a16a2025-04-30 02:49:25 +00001496 // Process initial user message
1497 initialResp, err := a.processUserMessage(ctx)
1498 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001499 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001500 return err
1501 }
1502
1503 // Handle edge case where both initialResp and err are nil
1504 if initialResp == nil {
1505 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001506 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1507
Sean McCullough9f4b8082025-04-30 17:34:07 +00001508 a.pushToOutbox(ctx, errorMessage(err))
1509 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001510 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001511
Earl Lee2e463fb2025-04-17 11:22:22 -07001512 // We do this as we go, but let's also do it at the end of the turn
1513 defer func() {
1514 if _, err := a.handleGitCommits(ctx); err != nil {
1515 // Just log the error, don't stop execution
1516 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1517 }
1518 }()
1519
Sean McCullougha1e0e492025-05-01 10:51:08 -07001520 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001521 resp := initialResp
1522 for {
1523 // Check if we are over budget
1524 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001525 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001526 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001527 }
1528
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001529 // Check if we should compact the conversation
1530 if a.ShouldCompact() {
1531 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1532 if err := a.CompactConversation(ctx); err != nil {
1533 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1534 return err
1535 }
1536 // After compaction, end this turn and start fresh
1537 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1538 return nil
1539 }
1540
Sean McCullough885a16a2025-04-30 02:49:25 +00001541 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001542 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001543 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001544 break
1545 }
1546
Sean McCullough96b60dd2025-04-30 09:49:10 -07001547 // Transition to tool use requested state
1548 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1549
Sean McCullough885a16a2025-04-30 02:49:25 +00001550 // Handle tool execution
1551 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1552 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001553 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001554 }
1555
Sean McCullougha1e0e492025-05-01 10:51:08 -07001556 if toolResp == nil {
1557 return fmt.Errorf("cannot continue conversation with a nil tool response")
1558 }
1559
Sean McCullough885a16a2025-04-30 02:49:25 +00001560 // Set the response for the next iteration
1561 resp = toolResp
1562 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001563
1564 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001565}
1566
1567// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001568func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001569 // Wait for at least one message from the user
1570 msgs, err := a.GatherMessages(ctx, true)
1571 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001572 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001573 return nil, err
1574 }
1575
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001576 userMessage := llm.Message{
1577 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001578 Content: msgs,
1579 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001580
Sean McCullough96b60dd2025-04-30 09:49:10 -07001581 // Transition to sending to LLM state
1582 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1583
Sean McCullough885a16a2025-04-30 02:49:25 +00001584 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001585 resp, err := a.convo.SendMessage(userMessage)
1586 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001587 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001588 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001589 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001590 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001591
Sean McCullough96b60dd2025-04-30 09:49:10 -07001592 // Transition to processing LLM response state
1593 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1594
Sean McCullough885a16a2025-04-30 02:49:25 +00001595 return resp, nil
1596}
1597
1598// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001599func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1600 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001601 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001602 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001603
Sean McCullough96b60dd2025-04-30 09:49:10 -07001604 // Transition to checking for cancellation state
1605 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1606
Sean McCullough885a16a2025-04-30 02:49:25 +00001607 // Check if the operation was cancelled by the user
1608 select {
1609 case <-ctx.Done():
1610 // Don't actually run any of the tools, but rather build a response
1611 // for each tool_use message letting the LLM know that user canceled it.
1612 var err error
1613 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001614 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001615 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001616 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001617 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001618 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001619 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001620 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001621 // Transition to running tool state
1622 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1623
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001624 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001625 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001626 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001627
1628 // Execute the tools
1629 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001630 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001631 if ctx.Err() != nil { // e.g. the user canceled the operation
1632 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001633 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001634 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001635 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001636 a.pushToOutbox(ctx, errorMessage(err))
1637 }
1638 }
1639
1640 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001641 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001642 autoqualityMessages := a.processGitChanges(ctx)
1643
1644 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001645 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001646 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001647 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001648 return false, nil
1649 }
1650
1651 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001652 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1653 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001654}
1655
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001656// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001657func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001658 // Check for git commits
1659 _, err := a.handleGitCommits(ctx)
1660 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001661 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001662 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001663 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001664 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001665}
1666
1667// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1668// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001669func (a *Agent) processGitChanges(ctx context.Context) []string {
1670 // Check for git commits after tool execution
1671 newCommits, err := a.handleGitCommits(ctx)
1672 if err != nil {
1673 // Just log the error, don't stop execution
1674 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1675 return nil
1676 }
1677
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001678 // Run mechanical checks if there was exactly one new commit.
1679 if len(newCommits) != 1 {
1680 return nil
1681 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001682 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001683 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1684 msg := a.codereview.RunMechanicalChecks(ctx)
1685 if msg != "" {
1686 a.pushToOutbox(ctx, AgentMessage{
1687 Type: AutoMessageType,
1688 Content: msg,
1689 Timestamp: time.Now(),
1690 })
1691 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001692 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001693
1694 return autoqualityMessages
1695}
1696
1697// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001698func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001699 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001700 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001701 msgs, err := a.GatherMessages(ctx, false)
1702 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001703 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001704 return false, nil
1705 }
1706
1707 // Inject any auto-generated messages from quality checks
1708 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001709 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001710 }
1711
1712 // Handle cancellation by appending a message about it
1713 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001714 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001715 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001716 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001717 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1718 } else if err := a.convo.OverBudget(); err != nil {
1719 // Handle budget issues by appending a message about it
1720 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 -07001721 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001722 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1723 }
1724
1725 // Combine tool results with user messages
1726 results = append(results, msgs...)
1727
1728 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001729 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001730 resp, err := a.convo.SendMessage(llm.Message{
1731 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001732 Content: results,
1733 })
1734 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001735 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001736 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1737 return true, nil // Return true to continue the conversation, but with no response
1738 }
1739
Sean McCullough96b60dd2025-04-30 09:49:10 -07001740 // Transition back to processing LLM response
1741 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1742
Sean McCullough885a16a2025-04-30 02:49:25 +00001743 if cancelled {
1744 return false, nil
1745 }
1746
1747 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001748}
1749
1750func (a *Agent) overBudget(ctx context.Context) error {
1751 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001752 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001753 m := budgetMessage(err)
1754 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001755 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001756 a.convo.ResetBudget(a.originalBudget)
1757 return err
1758 }
1759 return nil
1760}
1761
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001762func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001763 // Collect all text content
1764 var allText strings.Builder
1765 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001766 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001767 if allText.Len() > 0 {
1768 allText.WriteString("\n\n")
1769 }
1770 allText.WriteString(content.Text)
1771 }
1772 }
1773 return allText.String()
1774}
1775
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001776func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001777 a.mu.Lock()
1778 defer a.mu.Unlock()
1779 return a.convo.CumulativeUsage()
1780}
1781
Earl Lee2e463fb2025-04-17 11:22:22 -07001782// Diff returns a unified diff of changes made since the agent was instantiated.
1783func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001784 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001785 return "", fmt.Errorf("no initial commit reference available")
1786 }
1787
1788 // Find the repository root
1789 ctx := context.Background()
1790
1791 // If a specific commit hash is provided, show just that commit's changes
1792 if commit != nil && *commit != "" {
1793 // Validate that the commit looks like a valid git SHA
1794 if !isValidGitSHA(*commit) {
1795 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1796 }
1797
1798 // Get the diff for just this commit
1799 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1800 cmd.Dir = a.repoRoot
1801 output, err := cmd.CombinedOutput()
1802 if err != nil {
1803 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1804 }
1805 return string(output), nil
1806 }
1807
1808 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001809 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001810 cmd.Dir = a.repoRoot
1811 output, err := cmd.CombinedOutput()
1812 if err != nil {
1813 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1814 }
1815
1816 return string(output), nil
1817}
1818
Philip Zeyliger49edc922025-05-14 09:45:45 -07001819// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1820// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1821func (a *Agent) SketchGitBaseRef() string {
1822 if a.IsInContainer() {
1823 return "sketch-base"
1824 } else {
1825 return "sketch-base-" + a.SessionID()
1826 }
1827}
1828
1829// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1830func (a *Agent) SketchGitBase() string {
1831 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1832 cmd.Dir = a.repoRoot
1833 output, err := cmd.CombinedOutput()
1834 if err != nil {
1835 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1836 return "HEAD"
1837 }
1838 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001839}
1840
Pokey Rule7a113622025-05-12 10:58:45 +01001841// removeGitHooks removes the Git hooks directory from the repository
1842func removeGitHooks(_ context.Context, repoPath string) error {
1843 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1844
1845 // Check if hooks directory exists
1846 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1847 // Directory doesn't exist, nothing to do
1848 return nil
1849 }
1850
1851 // Remove the hooks directory
1852 err := os.RemoveAll(hooksDir)
1853 if err != nil {
1854 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1855 }
1856
1857 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001858 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001859 if err != nil {
1860 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1861 }
1862
1863 return nil
1864}
1865
Philip Zeyligerf2872992025-05-22 10:35:28 -07001866func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001867 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001868 for _, msg := range msgs {
1869 a.pushToOutbox(ctx, msg)
1870 }
1871 return commits, error
1872}
1873
Earl Lee2e463fb2025-04-17 11:22:22 -07001874// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001875// under docker, new HEADs are pushed to a branch according to the slug.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001876func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001877 ags.mu.Lock()
1878 defer ags.mu.Unlock()
1879
1880 msgs := []AgentMessage{}
1881 if repoRoot == "" {
1882 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001883 }
1884
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001885 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07001886 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001887 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001888 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001889 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001890 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001891 }
1892 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001893 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07001894 }()
1895
1896 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1897 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1898 // to the last 100 commits.
1899 var commits []*GitCommit
1900
1901 // Get commits since the initial commit
1902 // Format: <hash>\0<subject>\0<body>\0
1903 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1904 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001905 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 -07001906 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001907 output, err := cmd.Output()
1908 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001909 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001910 }
1911
1912 // Parse git log output and filter out already seen commits
1913 parsedCommits := parseGitLog(string(output))
1914
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001915 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001916
1917 // Filter out commits we've already seen
1918 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001919 if commit.Hash == sketch {
1920 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07001921 }
1922
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001923 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
1924 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07001925 continue
1926 }
1927
1928 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001929 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001930
1931 // Add to our list of new commits
1932 commits = append(commits, &commit)
1933 }
1934
Philip Zeyligerf2872992025-05-22 10:35:28 -07001935 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001936 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001937 // 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 -07001938 sketchCommit = &GitCommit{}
1939 sketchCommit.Hash = sketch
1940 sketchCommit.Subject = "unknown"
1941 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07001942 }
1943
Earl Lee2e463fb2025-04-17 11:22:22 -07001944 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1945 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1946 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001947
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001948 // 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 +00001949 var out []byte
1950 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001951 originalRetryNumber := ags.retryNumber
1952 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001953 for retries := range 10 {
1954 if retries > 0 {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001955 ags.IncrementRetryNumber()
Philip Zeyliger113e2052025-05-09 21:59:40 +00001956 }
1957
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001958 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001959 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001960 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001961 out, err = cmd.CombinedOutput()
1962
1963 if err == nil {
1964 // Success! Break out of the retry loop
1965 break
1966 }
1967
1968 // Check if this is the "refusing to update checked out branch" error
1969 if !strings.Contains(string(out), "refusing to update checked out branch") {
1970 // This is a different error, so don't retry
1971 break
1972 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00001973 }
1974
1975 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001976 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001977 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001978 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001979 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001980 if ags.retryNumber != originalRetryNumber {
1981 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001982 msgs = append(msgs, AgentMessage{
1983 Type: AutoMessageType,
1984 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001985 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 +00001986 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00001987 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001988 }
1989 }
1990
1991 // If we found new commits, create a message
1992 if len(commits) > 0 {
1993 msg := AgentMessage{
1994 Type: CommitMessageType,
1995 Timestamp: time.Now(),
1996 Commits: commits,
1997 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001998 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001999 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002000 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002001}
2002
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002003func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002004 return strings.Map(func(r rune) rune {
2005 // lowercase
2006 if r >= 'A' && r <= 'Z' {
2007 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002008 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002009 // replace spaces with dashes
2010 if r == ' ' {
2011 return '-'
2012 }
2013 // allow alphanumerics and dashes
2014 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2015 return r
2016 }
2017 return -1
2018 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002019}
2020
2021// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2022// and returns an array of GitCommit structs.
2023func parseGitLog(output string) []GitCommit {
2024 var commits []GitCommit
2025
2026 // No output means no commits
2027 if len(output) == 0 {
2028 return commits
2029 }
2030
2031 // Split by NULL byte
2032 parts := strings.Split(output, "\x00")
2033
2034 // Process in triplets (hash, subject, body)
2035 for i := 0; i < len(parts); i++ {
2036 // Skip empty parts
2037 if parts[i] == "" {
2038 continue
2039 }
2040
2041 // This should be a hash
2042 hash := strings.TrimSpace(parts[i])
2043
2044 // Make sure we have at least a subject part available
2045 if i+1 >= len(parts) {
2046 break // No more parts available
2047 }
2048
2049 // Get the subject
2050 subject := strings.TrimSpace(parts[i+1])
2051
2052 // Get the body if available
2053 body := ""
2054 if i+2 < len(parts) {
2055 body = strings.TrimSpace(parts[i+2])
2056 }
2057
2058 // Skip to the next triplet
2059 i += 2
2060
2061 commits = append(commits, GitCommit{
2062 Hash: hash,
2063 Subject: subject,
2064 Body: body,
2065 })
2066 }
2067
2068 return commits
2069}
2070
2071func repoRoot(ctx context.Context, dir string) (string, error) {
2072 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2073 stderr := new(strings.Builder)
2074 cmd.Stderr = stderr
2075 cmd.Dir = dir
2076 out, err := cmd.Output()
2077 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002078 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002079 }
2080 return strings.TrimSpace(string(out)), nil
2081}
2082
2083func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2084 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2085 stderr := new(strings.Builder)
2086 cmd.Stderr = stderr
2087 cmd.Dir = dir
2088 out, err := cmd.Output()
2089 if err != nil {
2090 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2091 }
2092 // TODO: validate that out is valid hex
2093 return strings.TrimSpace(string(out)), nil
2094}
2095
2096// isValidGitSHA validates if a string looks like a valid git SHA hash.
2097// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2098func isValidGitSHA(sha string) bool {
2099 // Git SHA must be a hexadecimal string with at least 4 characters
2100 if len(sha) < 4 || len(sha) > 40 {
2101 return false
2102 }
2103
2104 // Check if the string only contains hexadecimal characters
2105 for _, char := range sha {
2106 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2107 return false
2108 }
2109 }
2110
2111 return true
2112}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002113
2114// getGitOrigin returns the URL of the git remote 'origin' if it exists
2115func getGitOrigin(ctx context.Context, dir string) string {
2116 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2117 cmd.Dir = dir
2118 stderr := new(strings.Builder)
2119 cmd.Stderr = stderr
2120 out, err := cmd.Output()
2121 if err != nil {
2122 return ""
2123 }
2124 return strings.TrimSpace(string(out))
2125}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002126
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002127// systemPromptData contains the data used to render the system prompt template
2128type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002129 ClientGOOS string
2130 ClientGOARCH string
2131 WorkingDir string
2132 RepoRoot string
2133 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002134 Codebase *onstart.Codebase
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002135 UseSketchWIP bool
2136 Branch string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002137}
2138
2139// renderSystemPrompt renders the system prompt template.
2140func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002141 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002142 ClientGOOS: a.config.ClientGOOS,
2143 ClientGOARCH: a.config.ClientGOARCH,
2144 WorkingDir: a.workingDir,
2145 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002146 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002147 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002148 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002149 }
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002150 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2151 if err != nil {
2152 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2153 }
2154 buf := new(strings.Builder)
2155 err = tmpl.Execute(buf, data)
2156 if err != nil {
2157 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2158 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002159 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002160 return buf.String()
2161}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002162
2163// StateTransitionIterator provides an iterator over state transitions.
2164type StateTransitionIterator interface {
2165 // Next blocks until a new state transition is available or context is done.
2166 // Returns nil if the context is cancelled.
2167 Next() *StateTransition
2168 // Close removes the listener and cleans up resources.
2169 Close()
2170}
2171
2172// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2173type StateTransitionIteratorImpl struct {
2174 agent *Agent
2175 ctx context.Context
2176 ch chan StateTransition
2177 unsubscribe func()
2178}
2179
2180// Next blocks until a new state transition is available or the context is cancelled.
2181func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2182 select {
2183 case <-s.ctx.Done():
2184 return nil
2185 case transition, ok := <-s.ch:
2186 if !ok {
2187 return nil
2188 }
2189 transitionCopy := transition
2190 return &transitionCopy
2191 }
2192}
2193
2194// Close removes the listener and cleans up resources.
2195func (s *StateTransitionIteratorImpl) Close() {
2196 if s.unsubscribe != nil {
2197 s.unsubscribe()
2198 s.unsubscribe = nil
2199 }
2200}
2201
2202// NewStateTransitionIterator returns an iterator that receives state transitions.
2203func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2204 a.mu.Lock()
2205 defer a.mu.Unlock()
2206
2207 // Create channel to receive state transitions
2208 ch := make(chan StateTransition, 10)
2209
2210 // Add a listener to the state machine
2211 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2212
2213 return &StateTransitionIteratorImpl{
2214 agent: a,
2215 ctx: ctx,
2216 ch: ch,
2217 unsubscribe: unsubscribe,
2218 }
2219}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002220
2221// setupGitHooks creates or updates git hooks in the specified working directory.
2222func setupGitHooks(workingDir string) error {
2223 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2224
2225 _, err := os.Stat(hooksDir)
2226 if os.IsNotExist(err) {
2227 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2228 }
2229 if err != nil {
2230 return fmt.Errorf("error checking git hooks directory: %w", err)
2231 }
2232
2233 // Define the post-commit hook content
2234 postCommitHook := `#!/bin/bash
2235echo "<post_commit_hook>"
2236echo "Please review this commit message and fix it if it is incorrect."
2237echo "This hook only echos the commit message; it does not modify it."
2238echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2239echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002240PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002241echo "</last_commit_message>"
2242echo "</post_commit_hook>"
2243`
2244
2245 // Define the prepare-commit-msg hook content
2246 prepareCommitMsgHook := `#!/bin/bash
2247# Add Co-Authored-By and Change-ID trailers to commit messages
2248# Check if these trailers already exist before adding them
2249
2250commit_file="$1"
2251COMMIT_SOURCE="$2"
2252
2253# Skip for merges, squashes, or when using a commit template
2254if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2255 [ "$COMMIT_SOURCE" = "squash" ]; then
2256 exit 0
2257fi
2258
2259commit_msg=$(cat "$commit_file")
2260
2261needs_co_author=true
2262needs_change_id=true
2263
2264# Check if commit message already has Co-Authored-By trailer
2265if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2266 needs_co_author=false
2267fi
2268
2269# Check if commit message already has Change-ID trailer
2270if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2271 needs_change_id=false
2272fi
2273
2274# Only modify if at least one trailer needs to be added
2275if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002276 # Ensure there's a proper blank line before trailers
2277 if [ -s "$commit_file" ]; then
2278 # Check if file ends with newline by reading last character
2279 last_char=$(tail -c 1 "$commit_file")
2280
2281 if [ "$last_char" != "" ]; then
2282 # File doesn't end with newline - add two newlines (complete line + blank line)
2283 echo "" >> "$commit_file"
2284 echo "" >> "$commit_file"
2285 else
2286 # File ends with newline - check if we already have a blank line
2287 last_line=$(tail -1 "$commit_file")
2288 if [ -n "$last_line" ]; then
2289 # Last line has content - add one newline for blank line
2290 echo "" >> "$commit_file"
2291 fi
2292 # If last line is empty, we already have a blank line - don't add anything
2293 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002294 fi
2295
2296 # Add trailers if needed
2297 if [ "$needs_co_author" = true ]; then
2298 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2299 fi
2300
2301 if [ "$needs_change_id" = true ]; then
2302 change_id=$(openssl rand -hex 8)
2303 echo "Change-ID: s${change_id}k" >> "$commit_file"
2304 fi
2305fi
2306`
2307
2308 // Update or create the post-commit hook
2309 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2310 if err != nil {
2311 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2312 }
2313
2314 // Update or create the prepare-commit-msg hook
2315 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2316 if err != nil {
2317 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2318 }
2319
2320 return nil
2321}
2322
2323// updateOrCreateHook creates a new hook file or updates an existing one
2324// by appending the new content if it doesn't already contain it.
2325func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2326 // Check if the hook already exists
2327 buf, err := os.ReadFile(hookPath)
2328 if os.IsNotExist(err) {
2329 // Hook doesn't exist, create it
2330 err = os.WriteFile(hookPath, []byte(content), 0o755)
2331 if err != nil {
2332 return fmt.Errorf("failed to create hook: %w", err)
2333 }
2334 return nil
2335 }
2336 if err != nil {
2337 return fmt.Errorf("error reading existing hook: %w", err)
2338 }
2339
2340 // Hook exists, check if our content is already in it by looking for a distinctive line
2341 code := string(buf)
2342 if strings.Contains(code, distinctiveLine) {
2343 // Already contains our content, nothing to do
2344 return nil
2345 }
2346
2347 // Append our content to the existing hook
2348 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2349 if err != nil {
2350 return fmt.Errorf("failed to open hook for appending: %w", err)
2351 }
2352 defer f.Close()
2353
2354 // Ensure there's a newline at the end of the existing content if needed
2355 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2356 _, err = f.WriteString("\n")
2357 if err != nil {
2358 return fmt.Errorf("failed to add newline to hook: %w", err)
2359 }
2360 }
2361
2362 // Add a separator before our content
2363 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2364 if err != nil {
2365 return fmt.Errorf("failed to append to hook: %w", err)
2366 }
2367
2368 return nil
2369}
Sean McCullough138ec242025-06-02 22:42:06 +00002370
2371// GetPortMonitor returns the port monitor instance for accessing port events
2372func (a *Agent) GetPortMonitor() *PortMonitor {
2373 return a.portMonitor
2374}
Philip Zeyliger0113be52025-06-07 23:53:41 +00002375
2376// SkabandAddr returns the skaband address if configured
2377func (a *Agent) SkabandAddr() string {
2378 if a.config.SkabandClient != nil {
2379 return a.config.SkabandClient.Addr()
2380 }
2381 return ""
2382}