blob: 2cf2e4c1cfd0e7bef906b4fac92931894d9343a2 [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
Sean McCulloughedc88dc2025-04-30 02:55:01 +000071 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070072
73 CancelToolUse(toolUseID string, cause error) error
74
75 // Returns a subset of the agent's message history.
76 Messages(start int, end int) []AgentMessage
77
78 // Returns the current number of messages in the history
79 MessageCount() int
80
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070081 TotalUsage() conversation.CumulativeUsage
82 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070083
Earl Lee2e463fb2025-04-17 11:22:22 -070084 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000085 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070086
87 // Diff returns a unified diff of changes made since the agent was instantiated.
88 // If commit is non-nil, it shows the diff for just that specific commit.
89 Diff(commit *string) (string, error)
90
Philip Zeyliger49edc922025-05-14 09:45:45 -070091 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
92 // starts out as the commit where sketch started, but a user can move it if need
93 // be, for example in the case of a rebase. It is stored as a git tag.
94 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070095
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000096 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
97 // (Typically, this is "sketch-base")
98 SketchGitBaseRef() string
99
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700100 // Slug returns the slug identifier for this session.
101 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700102
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000103 // BranchName returns the git branch name for the conversation.
104 BranchName() string
105
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700106 // IncrementRetryNumber increments the retry number for branch naming conflicts.
107 IncrementRetryNumber()
108
Earl Lee2e463fb2025-04-17 11:22:22 -0700109 // OS returns the operating system of the client.
110 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000111
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000112 // SessionID returns the unique session identifier.
113 SessionID() string
114
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000115 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700116 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000117
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000118 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
119 OutstandingLLMCallCount() int
120
121 // OutstandingToolCalls returns the names of outstanding tool calls.
122 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000123 OutsideOS() string
124 OutsideHostname() string
125 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000126 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000127 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
128 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700129
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700130 // IsInContainer returns true if the agent is running in a container
131 IsInContainer() bool
132 // FirstMessageIndex returns the index of the first message in the current conversation
133 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700134
135 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700136 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
137 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700138
139 // CompactConversation compacts the current conversation by generating a summary
140 // and restarting the conversation with that summary as the initial context
141 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000142 // GetPortMonitor returns the port monitor instance for accessing port events
143 GetPortMonitor() *PortMonitor
Philip Zeyliger0113be52025-06-07 23:53:41 +0000144 // SkabandAddr returns the skaband address if configured
145 SkabandAddr() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700146}
147
148type CodingAgentMessageType string
149
150const (
151 UserMessageType CodingAgentMessageType = "user"
152 AgentMessageType CodingAgentMessageType = "agent"
153 ErrorMessageType CodingAgentMessageType = "error"
154 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
155 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700156 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
157 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
158 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700159
160 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
161)
162
163type AgentMessage struct {
164 Type CodingAgentMessageType `json:"type"`
165 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
166 EndOfTurn bool `json:"end_of_turn"`
167
168 Content string `json:"content"`
169 ToolName string `json:"tool_name,omitempty"`
170 ToolInput string `json:"input,omitempty"`
171 ToolResult string `json:"tool_result,omitempty"`
172 ToolError bool `json:"tool_error,omitempty"`
173 ToolCallId string `json:"tool_call_id,omitempty"`
174
175 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
176 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
177
Sean McCulloughd9f13372025-04-21 15:08:49 -0700178 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
179 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
180
Earl Lee2e463fb2025-04-17 11:22:22 -0700181 // Commits is a list of git commits for a commit message
182 Commits []*GitCommit `json:"commits,omitempty"`
183
184 Timestamp time.Time `json:"timestamp"`
185 ConversationID string `json:"conversation_id"`
186 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700187 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700188
189 // Message timing information
190 StartTime *time.Time `json:"start_time,omitempty"`
191 EndTime *time.Time `json:"end_time,omitempty"`
192 Elapsed *time.Duration `json:"elapsed,omitempty"`
193
194 // Turn duration - the time taken for a complete agent turn
195 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
196
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000197 // HideOutput indicates that this message should not be rendered in the UI.
198 // This is useful for subconversations that generate output that shouldn't be shown to the user.
199 HideOutput bool `json:"hide_output,omitempty"`
200
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700201 // TodoContent contains the agent's todo file content when it has changed
202 TodoContent *string `json:"todo_content,omitempty"`
203
Earl Lee2e463fb2025-04-17 11:22:22 -0700204 Idx int `json:"idx"`
205}
206
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000207// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700208func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700209 if convo == nil {
210 m.ConversationID = ""
211 m.ParentConversationID = nil
212 return
213 }
214 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000215 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700216 if convo.Parent != nil {
217 m.ParentConversationID = &convo.Parent.ID
218 }
219}
220
Earl Lee2e463fb2025-04-17 11:22:22 -0700221// GitCommit represents a single git commit for a commit message
222type GitCommit struct {
223 Hash string `json:"hash"` // Full commit hash
224 Subject string `json:"subject"` // Commit subject line
225 Body string `json:"body"` // Full commit message body
226 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
227}
228
229// ToolCall represents a single tool call within an agent message
230type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700231 Name string `json:"name"`
232 Input string `json:"input"`
233 ToolCallId string `json:"tool_call_id"`
234 ResultMessage *AgentMessage `json:"result_message,omitempty"`
235 Args string `json:"args,omitempty"`
236 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700237}
238
239func (a *AgentMessage) Attr() slog.Attr {
240 var attrs []any = []any{
241 slog.String("type", string(a.Type)),
242 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700243 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700244 if a.EndOfTurn {
245 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
246 }
247 if a.Content != "" {
248 attrs = append(attrs, slog.String("content", a.Content))
249 }
250 if a.ToolName != "" {
251 attrs = append(attrs, slog.String("tool_name", a.ToolName))
252 }
253 if a.ToolInput != "" {
254 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
255 }
256 if a.Elapsed != nil {
257 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
258 }
259 if a.TurnDuration != nil {
260 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
261 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700262 if len(a.ToolResult) > 0 {
263 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700264 }
265 if a.ToolError {
266 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
267 }
268 if len(a.ToolCalls) > 0 {
269 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
270 for i, tc := range a.ToolCalls {
271 toolCallAttrs = append(toolCallAttrs, slog.Group(
272 fmt.Sprintf("tool_call_%d", i),
273 slog.String("name", tc.Name),
274 slog.String("input", tc.Input),
275 ))
276 }
277 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
278 }
279 if a.ConversationID != "" {
280 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
281 }
282 if a.ParentConversationID != nil {
283 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
284 }
285 if a.Usage != nil && !a.Usage.IsZero() {
286 attrs = append(attrs, a.Usage.Attr())
287 }
288 // TODO: timestamp, convo ids, idx?
289 return slog.Group("agent_message", attrs...)
290}
291
292func errorMessage(err error) AgentMessage {
293 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
294 if os.Getenv(("DEBUG")) == "1" {
295 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
296 }
297
298 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
299}
300
301func budgetMessage(err error) AgentMessage {
302 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
303}
304
305// ConvoInterface defines the interface for conversation interactions
306type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700307 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700308 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700309 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700310 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700311 SendMessage(message llm.Message) (*llm.Response, error)
312 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700313 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000314 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700315 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700316 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700317 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700318}
319
Philip Zeyligerf2872992025-05-22 10:35:28 -0700320// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700321// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700322// any time we notice we need to.
323type AgentGitState struct {
324 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700325 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700326 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000327 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700328 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700329 slug string // Human-readable session identifier
330 retryNumber int // Number to append when branch conflicts occur
Philip Zeyligerf2872992025-05-22 10:35:28 -0700331}
332
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700333func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700334 ags.mu.Lock()
335 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700336 if ags.slug != slug {
337 ags.retryNumber = 0
338 }
339 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700340}
341
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700342func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700343 ags.mu.Lock()
344 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700345 return ags.slug
346}
347
348func (ags *AgentGitState) IncrementRetryNumber() {
349 ags.mu.Lock()
350 defer ags.mu.Unlock()
351 ags.retryNumber++
352}
353
354// HasSeenCommits returns true if any commits have been processed
355func (ags *AgentGitState) HasSeenCommits() bool {
356 ags.mu.Lock()
357 defer ags.mu.Unlock()
358 return len(ags.seenCommits) > 0
359}
360
361func (ags *AgentGitState) RetryNumber() int {
362 ags.mu.Lock()
363 defer ags.mu.Unlock()
364 return ags.retryNumber
365}
366
367func (ags *AgentGitState) BranchName(prefix string) string {
368 ags.mu.Lock()
369 defer ags.mu.Unlock()
370 return ags.branchNameLocked(prefix)
371}
372
373func (ags *AgentGitState) branchNameLocked(prefix string) string {
374 if ags.slug == "" {
375 return ""
376 }
377 if ags.retryNumber == 0 {
378 return prefix + ags.slug
379 }
380 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700381}
382
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000383func (ags *AgentGitState) Upstream() string {
384 ags.mu.Lock()
385 defer ags.mu.Unlock()
386 return ags.upstream
387}
388
Earl Lee2e463fb2025-04-17 11:22:22 -0700389type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700390 convo ConvoInterface
391 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700392 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700393 workingDir string
394 repoRoot string // workingDir may be a subdir of repoRoot
395 url string
396 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000397 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700398 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000399 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700400 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700401 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000402 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700403 // State machine to track agent state
404 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000405 // Outside information
406 outsideHostname string
407 outsideOS string
408 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000409 // URL of the git remote 'origin' if it exists
410 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700411
412 // Time when the current turn started (reset at the beginning of InnerLoop)
413 startOfTurn time.Time
414
415 // Inbox - for messages from the user to the agent.
416 // sent on by UserMessage
417 // . e.g. when user types into the chat textarea
418 // read from by GatherMessages
419 inbox chan string
420
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000421 // protects cancelTurn
422 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700423 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000424 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700425
426 // protects following
427 mu sync.Mutex
428
429 // Stores all messages for this agent
430 history []AgentMessage
431
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700432 // Iterators add themselves here when they're ready to be notified of new messages.
433 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700434
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000435 // Track outstanding LLM call IDs
436 outstandingLLMCalls map[string]struct{}
437
438 // Track outstanding tool calls by ID with their names
439 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000440
441 // Port monitoring
442 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700443}
444
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700445// NewIterator implements CodingAgent.
446func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
447 a.mu.Lock()
448 defer a.mu.Unlock()
449
450 return &MessageIteratorImpl{
451 agent: a,
452 ctx: ctx,
453 nextMessageIdx: nextMessageIdx,
454 ch: make(chan *AgentMessage, 100),
455 }
456}
457
458type MessageIteratorImpl struct {
459 agent *Agent
460 ctx context.Context
461 nextMessageIdx int
462 ch chan *AgentMessage
463 subscribed bool
464}
465
466func (m *MessageIteratorImpl) Close() {
467 m.agent.mu.Lock()
468 defer m.agent.mu.Unlock()
469 // Delete ourselves from the subscribers list
470 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
471 return x == m.ch
472 })
473 close(m.ch)
474}
475
476func (m *MessageIteratorImpl) Next() *AgentMessage {
477 // We avoid subscription at creation to let ourselves catch up to "current state"
478 // before subscribing.
479 if !m.subscribed {
480 m.agent.mu.Lock()
481 if m.nextMessageIdx < len(m.agent.history) {
482 msg := &m.agent.history[m.nextMessageIdx]
483 m.nextMessageIdx++
484 m.agent.mu.Unlock()
485 return msg
486 }
487 // The next message doesn't exist yet, so let's subscribe
488 m.agent.subscribers = append(m.agent.subscribers, m.ch)
489 m.subscribed = true
490 m.agent.mu.Unlock()
491 }
492
493 for {
494 select {
495 case <-m.ctx.Done():
496 m.agent.mu.Lock()
497 // Delete ourselves from the subscribers list
498 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
499 return x == m.ch
500 })
501 m.subscribed = false
502 m.agent.mu.Unlock()
503 return nil
504 case msg, ok := <-m.ch:
505 if !ok {
506 // Close may have been called
507 return nil
508 }
509 if msg.Idx == m.nextMessageIdx {
510 m.nextMessageIdx++
511 return msg
512 }
513 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
514 panic("out of order message")
515 }
516 }
517}
518
Sean McCulloughd9d45812025-04-30 16:53:41 -0700519// Assert that Agent satisfies the CodingAgent interface.
520var _ CodingAgent = &Agent{}
521
522// StateName implements CodingAgent.
523func (a *Agent) CurrentStateName() string {
524 if a.stateMachine == nil {
525 return ""
526 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000527 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700528}
529
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700530// CurrentTodoContent returns the current todo list data as JSON.
531// It returns an empty string if no todos exist.
532func (a *Agent) CurrentTodoContent() string {
533 todoPath := claudetool.TodoFilePath(a.config.SessionID)
534 content, err := os.ReadFile(todoPath)
535 if err != nil {
536 return ""
537 }
538 return string(content)
539}
540
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700541// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
542func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
543 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.
544
545IMPORTANT: 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.
546
547Please create a detailed summary that includes:
548
5491. **User's Request**: What did the user originally ask me to do? What was their goal?
550
5512. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
552
5533. **Key Technical Decisions**: What important technical choices were made during our work and why?
554
5554. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
556
5575. **Next Steps**: What still needs to be done to complete the user's request?
558
5596. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
560
561Focus 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.
562
563Reply with ONLY the summary content - no meta-commentary about creating the summary.`
564
565 userMessage := llm.UserStringMessage(msg)
566 // Use a subconversation with history to get the summary
567 // TODO: We don't have any tools here, so we should have enough tokens
568 // to capture a summary, but we may need to modify the history (e.g., remove
569 // TODO data) to save on some tokens.
570 convo := a.convo.SubConvoWithHistory()
571
572 // Modify the system prompt to provide context about the original task
573 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000574 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 -0700575
576Your 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.
577
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000578Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700579
580 resp, err := convo.SendMessage(userMessage)
581 if err != nil {
582 a.pushToOutbox(ctx, errorMessage(err))
583 return "", err
584 }
585 textContent := collectTextContent(resp)
586
587 // Restore original system prompt (though this subconvo will be discarded)
588 convo.SystemPrompt = originalSystemPrompt
589
590 return textContent, nil
591}
592
593// CompactConversation compacts the current conversation by generating a summary
594// and restarting the conversation with that summary as the initial context
595func (a *Agent) CompactConversation(ctx context.Context) error {
596 summary, err := a.generateConversationSummary(ctx)
597 if err != nil {
598 return fmt.Errorf("failed to generate conversation summary: %w", err)
599 }
600
601 a.mu.Lock()
602
603 // Get usage information before resetting conversation
604 lastUsage := a.convo.LastUsage()
605 contextWindow := a.config.Service.TokenContextWindow()
606 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
607
608 // Reset conversation state but keep all other state (git, working dir, etc.)
609 a.firstMessageIndex = len(a.history)
610 a.convo = a.initConvo()
611
612 a.mu.Unlock()
613
614 // Create informative compaction message with token details
615 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
616 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
617 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
618
619 a.pushToOutbox(ctx, AgentMessage{
620 Type: CompactMessageType,
621 Content: compactionMsg,
622 })
623
624 a.pushToOutbox(ctx, AgentMessage{
625 Type: UserMessageType,
626 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),
627 })
628 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)
629
630 return nil
631}
632
Earl Lee2e463fb2025-04-17 11:22:22 -0700633func (a *Agent) URL() string { return a.url }
634
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000635// BranchName returns the git branch name for the conversation.
636func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700637 return a.gitState.BranchName(a.config.BranchPrefix)
638}
639
640// Slug returns the slug identifier for this conversation.
641func (a *Agent) Slug() string {
642 return a.gitState.Slug()
643}
644
645// IncrementRetryNumber increments the retry number for branch naming conflicts
646func (a *Agent) IncrementRetryNumber() {
647 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000648}
649
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000650// OutstandingLLMCallCount returns the number of outstanding LLM calls.
651func (a *Agent) OutstandingLLMCallCount() int {
652 a.mu.Lock()
653 defer a.mu.Unlock()
654 return len(a.outstandingLLMCalls)
655}
656
657// OutstandingToolCalls returns the names of outstanding tool calls.
658func (a *Agent) OutstandingToolCalls() []string {
659 a.mu.Lock()
660 defer a.mu.Unlock()
661
662 tools := make([]string, 0, len(a.outstandingToolCalls))
663 for _, toolName := range a.outstandingToolCalls {
664 tools = append(tools, toolName)
665 }
666 return tools
667}
668
Earl Lee2e463fb2025-04-17 11:22:22 -0700669// OS returns the operating system of the client.
670func (a *Agent) OS() string {
671 return a.config.ClientGOOS
672}
673
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000674func (a *Agent) SessionID() string {
675 return a.config.SessionID
676}
677
Philip Zeyliger18532b22025-04-23 21:11:46 +0000678// OutsideOS returns the operating system of the outside system.
679func (a *Agent) OutsideOS() string {
680 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000681}
682
Philip Zeyliger18532b22025-04-23 21:11:46 +0000683// OutsideHostname returns the hostname of the outside system.
684func (a *Agent) OutsideHostname() string {
685 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000686}
687
Philip Zeyliger18532b22025-04-23 21:11:46 +0000688// OutsideWorkingDir returns the working directory on the outside system.
689func (a *Agent) OutsideWorkingDir() string {
690 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000691}
692
693// GitOrigin returns the URL of the git remote 'origin' if it exists.
694func (a *Agent) GitOrigin() string {
695 return a.gitOrigin
696}
697
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000698func (a *Agent) OpenBrowser(url string) {
699 if !a.IsInContainer() {
700 browser.Open(url)
701 return
702 }
703 // We're in Docker, need to send a request to the Git server
704 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700705 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000706 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700707 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000708 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700709 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000710 return
711 }
712 defer resp.Body.Close()
713 if resp.StatusCode == http.StatusOK {
714 return
715 }
716 body, _ := io.ReadAll(resp.Body)
717 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
718}
719
Sean McCullough96b60dd2025-04-30 09:49:10 -0700720// CurrentState returns the current state of the agent's state machine.
721func (a *Agent) CurrentState() State {
722 return a.stateMachine.CurrentState()
723}
724
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700725func (a *Agent) IsInContainer() bool {
726 return a.config.InDocker
727}
728
729func (a *Agent) FirstMessageIndex() int {
730 a.mu.Lock()
731 defer a.mu.Unlock()
732 return a.firstMessageIndex
733}
734
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700735// SetSlug sets a human-readable identifier for the conversation.
736func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700737 a.mu.Lock()
738 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700739
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700740 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000741 convo, ok := a.convo.(*conversation.Convo)
742 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700743 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000744 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700745}
746
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000747// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700748func (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 +0000749 // Track the tool call
750 a.mu.Lock()
751 a.outstandingToolCalls[id] = toolName
752 a.mu.Unlock()
753}
754
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700755// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
756// If there's only one element in the array and it's a text type, it returns that text directly.
757// It also processes nested ToolResult arrays recursively.
758func contentToString(contents []llm.Content) string {
759 if len(contents) == 0 {
760 return ""
761 }
762
763 // If there's only one element and it's a text type, return it directly
764 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
765 return contents[0].Text
766 }
767
768 // Otherwise, concatenate all text content
769 var result strings.Builder
770 for _, content := range contents {
771 if content.Type == llm.ContentTypeText {
772 result.WriteString(content.Text)
773 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
774 // Recursively process nested tool results
775 result.WriteString(contentToString(content.ToolResult))
776 }
777 }
778
779 return result.String()
780}
781
Earl Lee2e463fb2025-04-17 11:22:22 -0700782// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700783func (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 +0000784 // Remove the tool call from outstanding calls
785 a.mu.Lock()
786 delete(a.outstandingToolCalls, toolID)
787 a.mu.Unlock()
788
Earl Lee2e463fb2025-04-17 11:22:22 -0700789 m := AgentMessage{
790 Type: ToolUseMessageType,
791 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700792 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700793 ToolError: content.ToolError,
794 ToolName: toolName,
795 ToolInput: string(toolInput),
796 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700797 StartTime: content.ToolUseStartTime,
798 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700799 }
800
801 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700802 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
803 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700804 m.Elapsed = &elapsed
805 }
806
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700807 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700808 a.pushToOutbox(ctx, m)
809}
810
811// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700812func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000813 a.mu.Lock()
814 defer a.mu.Unlock()
815 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700816 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
817}
818
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700819// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700820// that need to be displayed (as well as tool calls that we send along when
821// they're done). (It would be reasonable to also mention tool calls when they're
822// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700823func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000824 // Remove the LLM call from outstanding calls
825 a.mu.Lock()
826 delete(a.outstandingLLMCalls, id)
827 a.mu.Unlock()
828
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700829 if resp == nil {
830 // LLM API call failed
831 m := AgentMessage{
832 Type: ErrorMessageType,
833 Content: "API call failed, type 'continue' to try again",
834 }
835 m.SetConvo(convo)
836 a.pushToOutbox(ctx, m)
837 return
838 }
839
Earl Lee2e463fb2025-04-17 11:22:22 -0700840 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700841 if convo.Parent == nil { // subconvos never end the turn
842 switch resp.StopReason {
843 case llm.StopReasonToolUse:
844 // Check whether any of the tool calls are for tools that should end the turn
845 ToolSearch:
846 for _, part := range resp.Content {
847 if part.Type != llm.ContentTypeToolUse {
848 continue
849 }
Sean McCullough021557a2025-05-05 23:20:53 +0000850 // Find the tool by name
851 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700852 if tool.Name == part.ToolName {
853 endOfTurn = tool.EndsTurn
854 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000855 }
856 }
Sean McCullough021557a2025-05-05 23:20:53 +0000857 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700858 default:
859 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000860 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700861 }
862 m := AgentMessage{
863 Type: AgentMessageType,
864 Content: collectTextContent(resp),
865 EndOfTurn: endOfTurn,
866 Usage: &resp.Usage,
867 StartTime: resp.StartTime,
868 EndTime: resp.EndTime,
869 }
870
871 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700872 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700873 var toolCalls []ToolCall
874 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700875 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700876 toolCalls = append(toolCalls, ToolCall{
877 Name: part.ToolName,
878 Input: string(part.ToolInput),
879 ToolCallId: part.ID,
880 })
881 }
882 }
883 m.ToolCalls = toolCalls
884 }
885
886 // Calculate the elapsed time if both start and end times are set
887 if resp.StartTime != nil && resp.EndTime != nil {
888 elapsed := resp.EndTime.Sub(*resp.StartTime)
889 m.Elapsed = &elapsed
890 }
891
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700892 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700893 a.pushToOutbox(ctx, m)
894}
895
896// WorkingDir implements CodingAgent.
897func (a *Agent) WorkingDir() string {
898 return a.workingDir
899}
900
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000901// RepoRoot returns the git repository root directory.
902func (a *Agent) RepoRoot() string {
903 return a.repoRoot
904}
905
Earl Lee2e463fb2025-04-17 11:22:22 -0700906// MessageCount implements CodingAgent.
907func (a *Agent) MessageCount() int {
908 a.mu.Lock()
909 defer a.mu.Unlock()
910 return len(a.history)
911}
912
913// Messages implements CodingAgent.
914func (a *Agent) Messages(start int, end int) []AgentMessage {
915 a.mu.Lock()
916 defer a.mu.Unlock()
917 return slices.Clone(a.history[start:end])
918}
919
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700920// ShouldCompact checks if the conversation should be compacted based on token usage
921func (a *Agent) ShouldCompact() bool {
922 // Get the threshold from environment variable, default to 0.94 (94%)
923 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
924 // and a little bit of buffer.)
925 thresholdRatio := 0.94
926 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
927 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
928 thresholdRatio = parsed
929 }
930 }
931
932 // Get the most recent usage to check current context size
933 lastUsage := a.convo.LastUsage()
934
935 if lastUsage.InputTokens == 0 {
936 // No API calls made yet
937 return false
938 }
939
940 // Calculate the current context size from the last API call
941 // This includes all tokens that were part of the input context:
942 // - Input tokens (user messages, system prompt, conversation history)
943 // - Cache read tokens (cached parts of the context)
944 // - Cache creation tokens (new parts being cached)
945 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
946
947 // Get the service's token context window
948 service := a.config.Service
949 contextWindow := service.TokenContextWindow()
950
951 // Calculate threshold
952 threshold := uint64(float64(contextWindow) * thresholdRatio)
953
954 // Check if we've exceeded the threshold
955 return currentContextSize >= threshold
956}
957
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700958func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700959 return a.originalBudget
960}
961
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000962// Upstream returns the upstream branch for git work
963func (a *Agent) Upstream() string {
964 return a.gitState.Upstream()
965}
966
Earl Lee2e463fb2025-04-17 11:22:22 -0700967// AgentConfig contains configuration for creating a new Agent.
968type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000969 Context context.Context
970 Service llm.Service
971 Budget conversation.Budget
972 GitUsername string
973 GitEmail string
974 SessionID string
975 ClientGOOS string
976 ClientGOARCH string
977 InDocker bool
978 OneShot bool
979 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000980 // Outside information
981 OutsideHostname string
982 OutsideOS string
983 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700984
985 // Outtie's HTTP to, e.g., open a browser
986 OutsideHTTP string
987 // Outtie's Git server
988 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000989 // Upstream branch for git work
990 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700991 // Commit to checkout from Outtie
992 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000993 // Prefix for git branches created by sketch
994 BranchPrefix string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -0700995 // Skaband client for session history (optional)
996 SkabandClient *skabandclient.SkabandClient
Earl Lee2e463fb2025-04-17 11:22:22 -0700997}
998
999// NewAgent creates a new Agent.
1000// It is not usable until Init() is called.
1001func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001002 // Set default branch prefix if not specified
1003 if config.BranchPrefix == "" {
1004 config.BranchPrefix = "sketch/"
1005 }
1006
Earl Lee2e463fb2025-04-17 11:22:22 -07001007 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001008 config: config,
1009 ready: make(chan struct{}),
1010 inbox: make(chan string, 100),
1011 subscribers: make([]chan *AgentMessage, 0),
1012 startedAt: time.Now(),
1013 originalBudget: config.Budget,
1014 gitState: AgentGitState{
1015 seenCommits: make(map[string]bool),
1016 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001017 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001018 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001019 outsideHostname: config.OutsideHostname,
1020 outsideOS: config.OutsideOS,
1021 outsideWorkingDir: config.OutsideWorkingDir,
1022 outstandingLLMCalls: make(map[string]struct{}),
1023 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001024 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001025 workingDir: config.WorkingDir,
1026 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +00001027 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001028 }
1029 return agent
1030}
1031
1032type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001033 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001034
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001035 InDocker bool
1036 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001037}
1038
1039func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001040 if a.convo != nil {
1041 return fmt.Errorf("Agent.Init: already initialized")
1042 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001043 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001044 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001045
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001046 if !ini.NoGit {
1047 // Capture the original origin before we potentially replace it below
1048 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
1049 }
1050
Philip Zeyliger222bf412025-06-04 16:42:58 +00001051 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001052 if a.gitState.gitRemoteAddr != "" {
1053 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001054
1055 // Remove existing origin remote if it exists
1056 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001057 cmd.Dir = a.workingDir
1058 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001059 // Ignore error if origin doesn't exist
1060 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001061 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001062
1063 // Add the new remote as origin
1064 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001065 cmd.Dir = a.workingDir
1066 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001067 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001068 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001069
Philip Zeyligerf2872992025-05-22 10:35:28 -07001070 }
1071
1072 // If a commit was specified, we fetch and reset to it.
1073 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001074 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1075
Earl Lee2e463fb2025-04-17 11:22:22 -07001076 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001077 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001078 if out, err := cmd.CombinedOutput(); err != nil {
1079 return fmt.Errorf("git stash: %s: %v", out, err)
1080 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001081 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
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 fetch: %s: %w", out, err)
1085 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001086 // The -B resets the branch if it already exists (or creates it if it doesn't)
1087 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001088 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001089 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1090 // Remove git hooks if they exist and retry
1091 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001092 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001093 if _, statErr := os.Stat(hookPath); statErr == nil {
1094 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1095 slog.String("error", err.Error()),
1096 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001097 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001098 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1099 }
1100
1101 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001102 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1103 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001104 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001105 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 +01001106 }
1107 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001108 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001109 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001110 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001111 } else if a.IsInContainer() {
1112 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1113 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1114 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1115 cmd.Dir = a.workingDir
1116 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1117 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1118 }
1119 } else {
1120 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001121 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001122
1123 if ini.HostAddr != "" {
1124 a.url = "http://" + ini.HostAddr
1125 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001126
1127 if !ini.NoGit {
1128 repoRoot, err := repoRoot(ctx, a.workingDir)
1129 if err != nil {
1130 return fmt.Errorf("repoRoot: %w", err)
1131 }
1132 a.repoRoot = repoRoot
1133
Earl Lee2e463fb2025-04-17 11:22:22 -07001134 if err != nil {
1135 return fmt.Errorf("resolveRef: %w", err)
1136 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001137
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001138 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001139 if err := setupGitHooks(a.repoRoot); err != nil {
1140 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1141 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001142 }
1143
Philip Zeyliger49edc922025-05-14 09:45:45 -07001144 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1145 cmd.Dir = repoRoot
1146 if out, err := cmd.CombinedOutput(); err != nil {
1147 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1148 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001149
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001150 slog.Info("running codebase analysis")
1151 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1152 if err != nil {
1153 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001154 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001155 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001156
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001157 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001158 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001159 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001160 }
1161 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001162
Earl Lee2e463fb2025-04-17 11:22:22 -07001163 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001164 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001165 a.convo = a.initConvo()
1166 close(a.ready)
1167 return nil
1168}
1169
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001170//go:embed agent_system_prompt.txt
1171var agentSystemPrompt string
1172
Earl Lee2e463fb2025-04-17 11:22:22 -07001173// initConvo initializes the conversation.
1174// It must not be called until all agent fields are initialized,
1175// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001176func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001177 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001178 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001179 convo.PromptCaching = true
1180 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001181 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001182 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001183
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001184 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1185 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001186 if a.gitState.Slug() != "" {
1187 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001188 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001189 willCommit, err := bashkit.WillRunGitCommit(command)
1190 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001191 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001192 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001193 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001194 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001195 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001196 return nil
1197 }
1198
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001199 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001200
Earl Lee2e463fb2025-04-17 11:22:22 -07001201 // Register all tools with the conversation
1202 // When adding, removing, or modifying tools here, double-check that the termui tool display
1203 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001204
1205 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001206 _, supportsScreenshots := a.config.Service.(*ant.Service)
1207 var bTools []*llm.Tool
1208 var browserCleanup func()
1209
1210 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1211 // Add cleanup function to context cancel
1212 go func() {
1213 <-a.config.Context.Done()
1214 browserCleanup()
1215 }()
1216 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001217
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001218 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001219 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001220 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001221 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001222 }
1223
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001224 // One-shot mode is non-interactive, multiple choice requires human response
1225 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001226 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001227 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001228
1229 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001230
1231 // Add session history tools if skaband client is available
1232 if a.config.SkabandClient != nil {
1233 sessionHistoryTools := claudetool.CreateSessionHistoryTools(a.config.SkabandClient, a.config.SessionID, a.gitOrigin)
1234 convo.Tools = append(convo.Tools, sessionHistoryTools...)
1235 }
1236
Earl Lee2e463fb2025-04-17 11:22:22 -07001237 convo.Listener = a
1238 return convo
1239}
1240
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001241var multipleChoiceTool = &llm.Tool{
1242 Name: "multiplechoice",
1243 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.",
1244 EndsTurn: true,
1245 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001246 "type": "object",
1247 "description": "The question and a list of answers you would expect the user to choose from.",
1248 "properties": {
1249 "question": {
1250 "type": "string",
1251 "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?'"
1252 },
1253 "responseOptions": {
1254 "type": "array",
1255 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1256 "items": {
1257 "type": "object",
1258 "properties": {
1259 "caption": {
1260 "type": "string",
1261 "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'"
1262 },
1263 "responseText": {
1264 "type": "string",
1265 "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'"
1266 }
1267 },
1268 "required": ["caption", "responseText"]
1269 }
1270 }
1271 },
1272 "required": ["question", "responseOptions"]
1273}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001274 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1275 // The Run logic for "multiplechoice" tool is a no-op on the server.
1276 // The UI will present a list of options for the user to select from,
1277 // and that's it as far as "executing" the tool_use goes.
1278 // When the user *does* select one of the presented options, that
1279 // responseText gets sent as a chat message on behalf of the user.
1280 return llm.TextContent("end your turn and wait for the user to respond"), nil
1281 },
Sean McCullough485afc62025-04-28 14:28:39 -07001282}
1283
1284type MultipleChoiceOption struct {
1285 Caption string `json:"caption"`
1286 ResponseText string `json:"responseText"`
1287}
1288
1289type MultipleChoiceParams struct {
1290 Question string `json:"question"`
1291 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1292}
1293
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001294// branchExists reports whether branchName exists, either locally or in well-known remotes.
1295func branchExists(dir, branchName string) bool {
1296 refs := []string{
1297 "refs/heads/",
1298 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001299 }
1300 for _, ref := range refs {
1301 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1302 cmd.Dir = dir
1303 if cmd.Run() == nil { // exit code 0 means branch exists
1304 return true
1305 }
1306 }
1307 return false
1308}
1309
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001310func (a *Agent) setSlugTool() *llm.Tool {
1311 return &llm.Tool{
1312 Name: "set-slug",
1313 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001314 InputSchema: json.RawMessage(`{
1315 "type": "object",
1316 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001317 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001318 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001319 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001320 }
1321 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001322 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001323}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001324 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001325 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001326 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001327 }
1328 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001329 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001330 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001331 // Prevent slug changes if there have been git changes
1332 // This lets the agent change its mind about a good slug,
1333 // while ensuring that once a branch has been pushed, it remains stable.
1334 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1335 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001336 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001337 if params.Slug == "" {
1338 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001339 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001340 slug := cleanSlugName(params.Slug)
1341 if slug == "" {
1342 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1343 }
1344 a.SetSlug(slug)
1345 // TODO: do this by a call to outie, rather than semi-guessing from innie
1346 if branchExists(a.workingDir, a.BranchName()) {
1347 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1348 }
1349 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001350 },
1351 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001352}
1353
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001354func (a *Agent) commitMessageStyleTool() *llm.Tool {
1355 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 +00001356 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001357 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001358 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001359 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001360 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001361 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1362 if err != nil {
1363 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1364 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001365 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001366 },
1367 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001368 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001369}
1370
1371func (a *Agent) Ready() <-chan struct{} {
1372 return a.ready
1373}
1374
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001375// BranchPrefix returns the configured branch prefix
1376func (a *Agent) BranchPrefix() string {
1377 return a.config.BranchPrefix
1378}
1379
Earl Lee2e463fb2025-04-17 11:22:22 -07001380func (a *Agent) UserMessage(ctx context.Context, msg string) {
1381 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1382 a.inbox <- msg
1383}
1384
Earl Lee2e463fb2025-04-17 11:22:22 -07001385func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1386 return a.convo.CancelToolUse(toolUseID, cause)
1387}
1388
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001389func (a *Agent) CancelTurn(cause error) {
1390 a.cancelTurnMu.Lock()
1391 defer a.cancelTurnMu.Unlock()
1392 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001393 // Force state transition to cancelled state
1394 ctx := a.config.Context
1395 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001396 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001397 }
1398}
1399
1400func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001401 // Start port monitoring when the agent loop begins
1402 // Only monitor ports when running in a container
1403 if a.IsInContainer() {
1404 a.portMonitor.Start(ctxOuter)
1405 }
1406
Earl Lee2e463fb2025-04-17 11:22:22 -07001407 for {
1408 select {
1409 case <-ctxOuter.Done():
1410 return
1411 default:
1412 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001413 a.cancelTurnMu.Lock()
1414 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001415 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001416 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001417 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001418 a.cancelTurn = cancel
1419 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001420 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1421 if err != nil {
1422 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1423 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001424 cancel(nil)
1425 }
1426 }
1427}
1428
1429func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1430 if m.Timestamp.IsZero() {
1431 m.Timestamp = time.Now()
1432 }
1433
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001434 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1435 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1436 m.Content = m.ToolResult
1437 }
1438
Earl Lee2e463fb2025-04-17 11:22:22 -07001439 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1440 if m.EndOfTurn && m.Type == AgentMessageType {
1441 turnDuration := time.Since(a.startOfTurn)
1442 m.TurnDuration = &turnDuration
1443 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1444 }
1445
Earl Lee2e463fb2025-04-17 11:22:22 -07001446 a.mu.Lock()
1447 defer a.mu.Unlock()
1448 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001449 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001450 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001451
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001452 // Notify all subscribers
1453 for _, ch := range a.subscribers {
1454 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001455 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001456}
1457
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001458func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1459 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001460 if block {
1461 select {
1462 case <-ctx.Done():
1463 return m, ctx.Err()
1464 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001465 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001466 }
1467 }
1468 for {
1469 select {
1470 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001471 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001472 default:
1473 return m, nil
1474 }
1475 }
1476}
1477
Sean McCullough885a16a2025-04-30 02:49:25 +00001478// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001479func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001480 // Reset the start of turn time
1481 a.startOfTurn = time.Now()
1482
Sean McCullough96b60dd2025-04-30 09:49:10 -07001483 // Transition to waiting for user input state
1484 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1485
Sean McCullough885a16a2025-04-30 02:49:25 +00001486 // Process initial user message
1487 initialResp, err := a.processUserMessage(ctx)
1488 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001489 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001490 return err
1491 }
1492
1493 // Handle edge case where both initialResp and err are nil
1494 if initialResp == nil {
1495 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001496 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1497
Sean McCullough9f4b8082025-04-30 17:34:07 +00001498 a.pushToOutbox(ctx, errorMessage(err))
1499 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001500 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001501
Earl Lee2e463fb2025-04-17 11:22:22 -07001502 // We do this as we go, but let's also do it at the end of the turn
1503 defer func() {
1504 if _, err := a.handleGitCommits(ctx); err != nil {
1505 // Just log the error, don't stop execution
1506 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1507 }
1508 }()
1509
Sean McCullougha1e0e492025-05-01 10:51:08 -07001510 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001511 resp := initialResp
1512 for {
1513 // Check if we are over budget
1514 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001515 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001516 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001517 }
1518
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001519 // Check if we should compact the conversation
1520 if a.ShouldCompact() {
1521 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1522 if err := a.CompactConversation(ctx); err != nil {
1523 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1524 return err
1525 }
1526 // After compaction, end this turn and start fresh
1527 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1528 return nil
1529 }
1530
Sean McCullough885a16a2025-04-30 02:49:25 +00001531 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001532 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001533 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001534 break
1535 }
1536
Sean McCullough96b60dd2025-04-30 09:49:10 -07001537 // Transition to tool use requested state
1538 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1539
Sean McCullough885a16a2025-04-30 02:49:25 +00001540 // Handle tool execution
1541 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1542 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001543 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001544 }
1545
Sean McCullougha1e0e492025-05-01 10:51:08 -07001546 if toolResp == nil {
1547 return fmt.Errorf("cannot continue conversation with a nil tool response")
1548 }
1549
Sean McCullough885a16a2025-04-30 02:49:25 +00001550 // Set the response for the next iteration
1551 resp = toolResp
1552 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001553
1554 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001555}
1556
1557// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001558func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001559 // Wait for at least one message from the user
1560 msgs, err := a.GatherMessages(ctx, true)
1561 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001562 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001563 return nil, err
1564 }
1565
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001566 userMessage := llm.Message{
1567 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001568 Content: msgs,
1569 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001570
Sean McCullough96b60dd2025-04-30 09:49:10 -07001571 // Transition to sending to LLM state
1572 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1573
Sean McCullough885a16a2025-04-30 02:49:25 +00001574 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001575 resp, err := a.convo.SendMessage(userMessage)
1576 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001577 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001578 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001579 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001580 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001581
Sean McCullough96b60dd2025-04-30 09:49:10 -07001582 // Transition to processing LLM response state
1583 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1584
Sean McCullough885a16a2025-04-30 02:49:25 +00001585 return resp, nil
1586}
1587
1588// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001589func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1590 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001591 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001592 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001593
Sean McCullough96b60dd2025-04-30 09:49:10 -07001594 // Transition to checking for cancellation state
1595 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1596
Sean McCullough885a16a2025-04-30 02:49:25 +00001597 // Check if the operation was cancelled by the user
1598 select {
1599 case <-ctx.Done():
1600 // Don't actually run any of the tools, but rather build a response
1601 // for each tool_use message letting the LLM know that user canceled it.
1602 var err error
1603 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001604 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001605 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001606 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001607 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001608 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001609 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001610 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001611 // Transition to running tool state
1612 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1613
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001614 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001615 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001616 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001617
1618 // Execute the tools
1619 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001620 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001621 if ctx.Err() != nil { // e.g. the user canceled the operation
1622 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001623 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001624 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001625 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001626 a.pushToOutbox(ctx, errorMessage(err))
1627 }
1628 }
1629
1630 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001631 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001632 autoqualityMessages := a.processGitChanges(ctx)
1633
1634 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001635 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001636 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001637 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001638 return false, nil
1639 }
1640
1641 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001642 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1643 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001644}
1645
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001646// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001647func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001648 // Check for git commits
1649 _, err := a.handleGitCommits(ctx)
1650 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001651 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001652 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001653 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001654 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001655}
1656
1657// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1658// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001659func (a *Agent) processGitChanges(ctx context.Context) []string {
1660 // Check for git commits after tool execution
1661 newCommits, err := a.handleGitCommits(ctx)
1662 if err != nil {
1663 // Just log the error, don't stop execution
1664 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1665 return nil
1666 }
1667
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001668 // Run mechanical checks if there was exactly one new commit.
1669 if len(newCommits) != 1 {
1670 return nil
1671 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001672 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001673 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1674 msg := a.codereview.RunMechanicalChecks(ctx)
1675 if msg != "" {
1676 a.pushToOutbox(ctx, AgentMessage{
1677 Type: AutoMessageType,
1678 Content: msg,
1679 Timestamp: time.Now(),
1680 })
1681 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001682 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001683
1684 return autoqualityMessages
1685}
1686
1687// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001688func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001689 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001690 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001691 msgs, err := a.GatherMessages(ctx, false)
1692 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001693 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001694 return false, nil
1695 }
1696
1697 // Inject any auto-generated messages from quality checks
1698 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001699 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001700 }
1701
1702 // Handle cancellation by appending a message about it
1703 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001704 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001705 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001706 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001707 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1708 } else if err := a.convo.OverBudget(); err != nil {
1709 // Handle budget issues by appending a message about it
1710 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 -07001711 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001712 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1713 }
1714
1715 // Combine tool results with user messages
1716 results = append(results, msgs...)
1717
1718 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001719 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001720 resp, err := a.convo.SendMessage(llm.Message{
1721 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001722 Content: results,
1723 })
1724 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001725 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001726 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1727 return true, nil // Return true to continue the conversation, but with no response
1728 }
1729
Sean McCullough96b60dd2025-04-30 09:49:10 -07001730 // Transition back to processing LLM response
1731 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1732
Sean McCullough885a16a2025-04-30 02:49:25 +00001733 if cancelled {
1734 return false, nil
1735 }
1736
1737 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001738}
1739
1740func (a *Agent) overBudget(ctx context.Context) error {
1741 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001742 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001743 m := budgetMessage(err)
1744 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001745 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001746 a.convo.ResetBudget(a.originalBudget)
1747 return err
1748 }
1749 return nil
1750}
1751
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001752func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001753 // Collect all text content
1754 var allText strings.Builder
1755 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001756 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001757 if allText.Len() > 0 {
1758 allText.WriteString("\n\n")
1759 }
1760 allText.WriteString(content.Text)
1761 }
1762 }
1763 return allText.String()
1764}
1765
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001766func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001767 a.mu.Lock()
1768 defer a.mu.Unlock()
1769 return a.convo.CumulativeUsage()
1770}
1771
Earl Lee2e463fb2025-04-17 11:22:22 -07001772// Diff returns a unified diff of changes made since the agent was instantiated.
1773func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001774 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001775 return "", fmt.Errorf("no initial commit reference available")
1776 }
1777
1778 // Find the repository root
1779 ctx := context.Background()
1780
1781 // If a specific commit hash is provided, show just that commit's changes
1782 if commit != nil && *commit != "" {
1783 // Validate that the commit looks like a valid git SHA
1784 if !isValidGitSHA(*commit) {
1785 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1786 }
1787
1788 // Get the diff for just this commit
1789 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1790 cmd.Dir = a.repoRoot
1791 output, err := cmd.CombinedOutput()
1792 if err != nil {
1793 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1794 }
1795 return string(output), nil
1796 }
1797
1798 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001799 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001800 cmd.Dir = a.repoRoot
1801 output, err := cmd.CombinedOutput()
1802 if err != nil {
1803 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1804 }
1805
1806 return string(output), nil
1807}
1808
Philip Zeyliger49edc922025-05-14 09:45:45 -07001809// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1810// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1811func (a *Agent) SketchGitBaseRef() string {
1812 if a.IsInContainer() {
1813 return "sketch-base"
1814 } else {
1815 return "sketch-base-" + a.SessionID()
1816 }
1817}
1818
1819// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1820func (a *Agent) SketchGitBase() string {
1821 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1822 cmd.Dir = a.repoRoot
1823 output, err := cmd.CombinedOutput()
1824 if err != nil {
1825 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1826 return "HEAD"
1827 }
1828 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001829}
1830
Pokey Rule7a113622025-05-12 10:58:45 +01001831// removeGitHooks removes the Git hooks directory from the repository
1832func removeGitHooks(_ context.Context, repoPath string) error {
1833 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1834
1835 // Check if hooks directory exists
1836 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1837 // Directory doesn't exist, nothing to do
1838 return nil
1839 }
1840
1841 // Remove the hooks directory
1842 err := os.RemoveAll(hooksDir)
1843 if err != nil {
1844 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1845 }
1846
1847 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001848 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001849 if err != nil {
1850 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1851 }
1852
1853 return nil
1854}
1855
Philip Zeyligerf2872992025-05-22 10:35:28 -07001856func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001857 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001858 for _, msg := range msgs {
1859 a.pushToOutbox(ctx, msg)
1860 }
1861 return commits, error
1862}
1863
Earl Lee2e463fb2025-04-17 11:22:22 -07001864// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001865// under docker, new HEADs are pushed to a branch according to the slug.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001866func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001867 ags.mu.Lock()
1868 defer ags.mu.Unlock()
1869
1870 msgs := []AgentMessage{}
1871 if repoRoot == "" {
1872 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001873 }
1874
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001875 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07001876 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001877 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001878 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001879 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001880 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001881 }
1882 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001883 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07001884 }()
1885
1886 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1887 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1888 // to the last 100 commits.
1889 var commits []*GitCommit
1890
1891 // Get commits since the initial commit
1892 // Format: <hash>\0<subject>\0<body>\0
1893 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1894 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001895 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 -07001896 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001897 output, err := cmd.Output()
1898 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001899 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001900 }
1901
1902 // Parse git log output and filter out already seen commits
1903 parsedCommits := parseGitLog(string(output))
1904
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001905 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001906
1907 // Filter out commits we've already seen
1908 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001909 if commit.Hash == sketch {
1910 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07001911 }
1912
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001913 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
1914 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07001915 continue
1916 }
1917
1918 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001919 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001920
1921 // Add to our list of new commits
1922 commits = append(commits, &commit)
1923 }
1924
Philip Zeyligerf2872992025-05-22 10:35:28 -07001925 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001926 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001927 // 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 -07001928 sketchCommit = &GitCommit{}
1929 sketchCommit.Hash = sketch
1930 sketchCommit.Subject = "unknown"
1931 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07001932 }
1933
Earl Lee2e463fb2025-04-17 11:22:22 -07001934 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1935 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1936 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001937
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001938 // 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 +00001939 var out []byte
1940 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001941 originalRetryNumber := ags.retryNumber
1942 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001943 for retries := range 10 {
1944 if retries > 0 {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001945 ags.IncrementRetryNumber()
Philip Zeyliger113e2052025-05-09 21:59:40 +00001946 }
1947
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001948 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001949 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001950 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001951 out, err = cmd.CombinedOutput()
1952
1953 if err == nil {
1954 // Success! Break out of the retry loop
1955 break
1956 }
1957
1958 // Check if this is the "refusing to update checked out branch" error
1959 if !strings.Contains(string(out), "refusing to update checked out branch") {
1960 // This is a different error, so don't retry
1961 break
1962 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00001963 }
1964
1965 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001966 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001967 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001968 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001969 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001970 if ags.retryNumber != originalRetryNumber {
1971 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001972 msgs = append(msgs, AgentMessage{
1973 Type: AutoMessageType,
1974 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001975 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 +00001976 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00001977 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001978 }
1979 }
1980
1981 // If we found new commits, create a message
1982 if len(commits) > 0 {
1983 msg := AgentMessage{
1984 Type: CommitMessageType,
1985 Timestamp: time.Now(),
1986 Commits: commits,
1987 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001988 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001989 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001990 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001991}
1992
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001993func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001994 return strings.Map(func(r rune) rune {
1995 // lowercase
1996 if r >= 'A' && r <= 'Z' {
1997 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001998 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001999 // replace spaces with dashes
2000 if r == ' ' {
2001 return '-'
2002 }
2003 // allow alphanumerics and dashes
2004 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2005 return r
2006 }
2007 return -1
2008 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002009}
2010
2011// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2012// and returns an array of GitCommit structs.
2013func parseGitLog(output string) []GitCommit {
2014 var commits []GitCommit
2015
2016 // No output means no commits
2017 if len(output) == 0 {
2018 return commits
2019 }
2020
2021 // Split by NULL byte
2022 parts := strings.Split(output, "\x00")
2023
2024 // Process in triplets (hash, subject, body)
2025 for i := 0; i < len(parts); i++ {
2026 // Skip empty parts
2027 if parts[i] == "" {
2028 continue
2029 }
2030
2031 // This should be a hash
2032 hash := strings.TrimSpace(parts[i])
2033
2034 // Make sure we have at least a subject part available
2035 if i+1 >= len(parts) {
2036 break // No more parts available
2037 }
2038
2039 // Get the subject
2040 subject := strings.TrimSpace(parts[i+1])
2041
2042 // Get the body if available
2043 body := ""
2044 if i+2 < len(parts) {
2045 body = strings.TrimSpace(parts[i+2])
2046 }
2047
2048 // Skip to the next triplet
2049 i += 2
2050
2051 commits = append(commits, GitCommit{
2052 Hash: hash,
2053 Subject: subject,
2054 Body: body,
2055 })
2056 }
2057
2058 return commits
2059}
2060
2061func repoRoot(ctx context.Context, dir string) (string, error) {
2062 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2063 stderr := new(strings.Builder)
2064 cmd.Stderr = stderr
2065 cmd.Dir = dir
2066 out, err := cmd.Output()
2067 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002068 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002069 }
2070 return strings.TrimSpace(string(out)), nil
2071}
2072
2073func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2074 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2075 stderr := new(strings.Builder)
2076 cmd.Stderr = stderr
2077 cmd.Dir = dir
2078 out, err := cmd.Output()
2079 if err != nil {
2080 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2081 }
2082 // TODO: validate that out is valid hex
2083 return strings.TrimSpace(string(out)), nil
2084}
2085
2086// isValidGitSHA validates if a string looks like a valid git SHA hash.
2087// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2088func isValidGitSHA(sha string) bool {
2089 // Git SHA must be a hexadecimal string with at least 4 characters
2090 if len(sha) < 4 || len(sha) > 40 {
2091 return false
2092 }
2093
2094 // Check if the string only contains hexadecimal characters
2095 for _, char := range sha {
2096 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2097 return false
2098 }
2099 }
2100
2101 return true
2102}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002103
2104// getGitOrigin returns the URL of the git remote 'origin' if it exists
2105func getGitOrigin(ctx context.Context, dir string) string {
2106 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2107 cmd.Dir = dir
2108 stderr := new(strings.Builder)
2109 cmd.Stderr = stderr
2110 out, err := cmd.Output()
2111 if err != nil {
2112 return ""
2113 }
2114 return strings.TrimSpace(string(out))
2115}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002116
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002117// systemPromptData contains the data used to render the system prompt template
2118type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002119 ClientGOOS string
2120 ClientGOARCH string
2121 WorkingDir string
2122 RepoRoot string
2123 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002124 Codebase *onstart.Codebase
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002125 UseSketchWIP bool
2126 Branch string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002127}
2128
2129// renderSystemPrompt renders the system prompt template.
2130func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002131 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002132 ClientGOOS: a.config.ClientGOOS,
2133 ClientGOARCH: a.config.ClientGOARCH,
2134 WorkingDir: a.workingDir,
2135 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002136 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002137 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002138 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002139 }
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002140 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2141 if err != nil {
2142 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2143 }
2144 buf := new(strings.Builder)
2145 err = tmpl.Execute(buf, data)
2146 if err != nil {
2147 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2148 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002149 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002150 return buf.String()
2151}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002152
2153// StateTransitionIterator provides an iterator over state transitions.
2154type StateTransitionIterator interface {
2155 // Next blocks until a new state transition is available or context is done.
2156 // Returns nil if the context is cancelled.
2157 Next() *StateTransition
2158 // Close removes the listener and cleans up resources.
2159 Close()
2160}
2161
2162// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2163type StateTransitionIteratorImpl struct {
2164 agent *Agent
2165 ctx context.Context
2166 ch chan StateTransition
2167 unsubscribe func()
2168}
2169
2170// Next blocks until a new state transition is available or the context is cancelled.
2171func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2172 select {
2173 case <-s.ctx.Done():
2174 return nil
2175 case transition, ok := <-s.ch:
2176 if !ok {
2177 return nil
2178 }
2179 transitionCopy := transition
2180 return &transitionCopy
2181 }
2182}
2183
2184// Close removes the listener and cleans up resources.
2185func (s *StateTransitionIteratorImpl) Close() {
2186 if s.unsubscribe != nil {
2187 s.unsubscribe()
2188 s.unsubscribe = nil
2189 }
2190}
2191
2192// NewStateTransitionIterator returns an iterator that receives state transitions.
2193func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2194 a.mu.Lock()
2195 defer a.mu.Unlock()
2196
2197 // Create channel to receive state transitions
2198 ch := make(chan StateTransition, 10)
2199
2200 // Add a listener to the state machine
2201 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2202
2203 return &StateTransitionIteratorImpl{
2204 agent: a,
2205 ctx: ctx,
2206 ch: ch,
2207 unsubscribe: unsubscribe,
2208 }
2209}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002210
2211// setupGitHooks creates or updates git hooks in the specified working directory.
2212func setupGitHooks(workingDir string) error {
2213 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2214
2215 _, err := os.Stat(hooksDir)
2216 if os.IsNotExist(err) {
2217 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2218 }
2219 if err != nil {
2220 return fmt.Errorf("error checking git hooks directory: %w", err)
2221 }
2222
2223 // Define the post-commit hook content
2224 postCommitHook := `#!/bin/bash
2225echo "<post_commit_hook>"
2226echo "Please review this commit message and fix it if it is incorrect."
2227echo "This hook only echos the commit message; it does not modify it."
2228echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2229echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002230PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002231echo "</last_commit_message>"
2232echo "</post_commit_hook>"
2233`
2234
2235 // Define the prepare-commit-msg hook content
2236 prepareCommitMsgHook := `#!/bin/bash
2237# Add Co-Authored-By and Change-ID trailers to commit messages
2238# Check if these trailers already exist before adding them
2239
2240commit_file="$1"
2241COMMIT_SOURCE="$2"
2242
2243# Skip for merges, squashes, or when using a commit template
2244if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2245 [ "$COMMIT_SOURCE" = "squash" ]; then
2246 exit 0
2247fi
2248
2249commit_msg=$(cat "$commit_file")
2250
2251needs_co_author=true
2252needs_change_id=true
2253
2254# Check if commit message already has Co-Authored-By trailer
2255if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2256 needs_co_author=false
2257fi
2258
2259# Check if commit message already has Change-ID trailer
2260if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2261 needs_change_id=false
2262fi
2263
2264# Only modify if at least one trailer needs to be added
2265if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002266 # Ensure there's a proper blank line before trailers
2267 if [ -s "$commit_file" ]; then
2268 # Check if file ends with newline by reading last character
2269 last_char=$(tail -c 1 "$commit_file")
2270
2271 if [ "$last_char" != "" ]; then
2272 # File doesn't end with newline - add two newlines (complete line + blank line)
2273 echo "" >> "$commit_file"
2274 echo "" >> "$commit_file"
2275 else
2276 # File ends with newline - check if we already have a blank line
2277 last_line=$(tail -1 "$commit_file")
2278 if [ -n "$last_line" ]; then
2279 # Last line has content - add one newline for blank line
2280 echo "" >> "$commit_file"
2281 fi
2282 # If last line is empty, we already have a blank line - don't add anything
2283 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002284 fi
2285
2286 # Add trailers if needed
2287 if [ "$needs_co_author" = true ]; then
2288 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2289 fi
2290
2291 if [ "$needs_change_id" = true ]; then
2292 change_id=$(openssl rand -hex 8)
2293 echo "Change-ID: s${change_id}k" >> "$commit_file"
2294 fi
2295fi
2296`
2297
2298 // Update or create the post-commit hook
2299 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2300 if err != nil {
2301 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2302 }
2303
2304 // Update or create the prepare-commit-msg hook
2305 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2306 if err != nil {
2307 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2308 }
2309
2310 return nil
2311}
2312
2313// updateOrCreateHook creates a new hook file or updates an existing one
2314// by appending the new content if it doesn't already contain it.
2315func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2316 // Check if the hook already exists
2317 buf, err := os.ReadFile(hookPath)
2318 if os.IsNotExist(err) {
2319 // Hook doesn't exist, create it
2320 err = os.WriteFile(hookPath, []byte(content), 0o755)
2321 if err != nil {
2322 return fmt.Errorf("failed to create hook: %w", err)
2323 }
2324 return nil
2325 }
2326 if err != nil {
2327 return fmt.Errorf("error reading existing hook: %w", err)
2328 }
2329
2330 // Hook exists, check if our content is already in it by looking for a distinctive line
2331 code := string(buf)
2332 if strings.Contains(code, distinctiveLine) {
2333 // Already contains our content, nothing to do
2334 return nil
2335 }
2336
2337 // Append our content to the existing hook
2338 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2339 if err != nil {
2340 return fmt.Errorf("failed to open hook for appending: %w", err)
2341 }
2342 defer f.Close()
2343
2344 // Ensure there's a newline at the end of the existing content if needed
2345 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2346 _, err = f.WriteString("\n")
2347 if err != nil {
2348 return fmt.Errorf("failed to add newline to hook: %w", err)
2349 }
2350 }
2351
2352 // Add a separator before our content
2353 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2354 if err != nil {
2355 return fmt.Errorf("failed to append to hook: %w", err)
2356 }
2357
2358 return nil
2359}
Sean McCullough138ec242025-06-02 22:42:06 +00002360
2361// GetPortMonitor returns the port monitor instance for accessing port events
2362func (a *Agent) GetPortMonitor() *PortMonitor {
2363 return a.portMonitor
2364}
Philip Zeyliger0113be52025-06-07 23:53:41 +00002365
2366// SkabandAddr returns the skaband address if configured
2367func (a *Agent) SkabandAddr() string {
2368 if a.config.SkabandClient != nil {
2369 return a.config.SkabandClient.Addr()
2370 }
2371 return ""
2372}