blob: 21e10d7c0cb4a6b7b190a178302772f54562325e [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 Zeyliger194bfa82025-06-24 06:03:06 -070031 "sketch.dev/mcp"
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070032 "sketch.dev/skabandclient"
Philip Zeyliger5f26a342025-07-04 01:30:29 +000033 "tailscale.com/portlist"
Earl Lee2e463fb2025-04-17 11:22:22 -070034)
35
36const (
37 userCancelMessage = "user requested agent to stop handling responses"
38)
39
Philip Zeyligerb7c58752025-05-01 10:10:17 -070040type MessageIterator interface {
41 // Next blocks until the next message is available. It may
42 // return nil if the underlying iterator context is done.
43 Next() *AgentMessage
44 Close()
45}
46
Earl Lee2e463fb2025-04-17 11:22:22 -070047type CodingAgent interface {
48 // Init initializes an agent inside a docker container.
49 Init(AgentInit) error
50
51 // Ready returns a channel closed after Init successfully called.
52 Ready() <-chan struct{}
53
54 // URL reports the HTTP URL of this agent.
55 URL() string
56
57 // UserMessage enqueues a message to the agent and returns immediately.
58 UserMessage(ctx context.Context, msg string)
59
Philip Zeyligerb7c58752025-05-01 10:10:17 -070060 // Returns an iterator that finishes when the context is done and
61 // starts with the given message index.
62 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070063
Philip Zeyligereab12de2025-05-14 02:35:53 +000064 // Returns an iterator that notifies of state transitions until the context is done.
65 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
66
Earl Lee2e463fb2025-04-17 11:22:22 -070067 // Loop begins the agent loop returns only when ctx is cancelled.
68 Loop(ctx context.Context)
69
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000070 // BranchPrefix returns the configured branch prefix
71 BranchPrefix() string
72
philip.zeyliger6d3de482025-06-10 19:38:14 -070073 // LinkToGitHub returns whether GitHub branch linking is enabled
74 LinkToGitHub() bool
75
Sean McCulloughedc88dc2025-04-30 02:55:01 +000076 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070077
78 CancelToolUse(toolUseID string, cause error) error
79
80 // Returns a subset of the agent's message history.
81 Messages(start int, end int) []AgentMessage
82
83 // Returns the current number of messages in the history
84 MessageCount() int
85
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070086 TotalUsage() conversation.CumulativeUsage
87 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070088
Earl Lee2e463fb2025-04-17 11:22:22 -070089 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000090 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070091
92 // Diff returns a unified diff of changes made since the agent was instantiated.
93 // If commit is non-nil, it shows the diff for just that specific commit.
94 Diff(commit *string) (string, error)
95
Philip Zeyliger49edc922025-05-14 09:45:45 -070096 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
97 // starts out as the commit where sketch started, but a user can move it if need
98 // be, for example in the case of a rebase. It is stored as a git tag.
99 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700100
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000101 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
102 // (Typically, this is "sketch-base")
103 SketchGitBaseRef() string
104
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700105 // Slug returns the slug identifier for this session.
106 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700107
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000108 // BranchName returns the git branch name for the conversation.
109 BranchName() string
110
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700111 // IncrementRetryNumber increments the retry number for branch naming conflicts.
112 IncrementRetryNumber()
113
Earl Lee2e463fb2025-04-17 11:22:22 -0700114 // OS returns the operating system of the client.
115 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000116
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000117 // SessionID returns the unique session identifier.
118 SessionID() string
119
philip.zeyliger8773e682025-06-11 21:36:21 -0700120 // SSHConnectionString returns the SSH connection string for the container.
121 SSHConnectionString() string
122
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000123 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700124 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000125
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000126 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
127 OutstandingLLMCallCount() int
128
129 // OutstandingToolCalls returns the names of outstanding tool calls.
130 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000131 OutsideOS() string
132 OutsideHostname() string
133 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000134 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700135
bankseancad67b02025-06-27 21:57:05 +0000136 // GitUsername returns the git user name from the agent config.
137 GitUsername() string
138
Philip Zeyliger64f60462025-06-16 13:57:10 -0700139 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
140 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000141 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
142 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700143
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700144 // IsInContainer returns true if the agent is running in a container
145 IsInContainer() bool
146 // FirstMessageIndex returns the index of the first message in the current conversation
147 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700148
149 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700150 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
151 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700152
153 // CompactConversation compacts the current conversation by generating a summary
154 // and restarting the conversation with that summary as the initial context
155 CompactConversation(ctx context.Context) error
Philip Zeyligerda623b52025-07-04 01:12:38 +0000156
Philip Zeyliger0113be52025-06-07 23:53:41 +0000157 // SkabandAddr returns the skaband address if configured
158 SkabandAddr() string
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000159
160 // GetPorts returns the cached list of open TCP ports
161 GetPorts() []portlist.Port
banksean5ab8fb82025-07-09 12:34:55 -0700162
163 // TokenContextWindow returns the TokenContextWindow size of the model the agent is using.
164 TokenContextWindow() int
Earl Lee2e463fb2025-04-17 11:22:22 -0700165}
166
167type CodingAgentMessageType string
168
169const (
170 UserMessageType CodingAgentMessageType = "user"
171 AgentMessageType CodingAgentMessageType = "agent"
172 ErrorMessageType CodingAgentMessageType = "error"
173 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
174 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700175 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
176 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
177 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000178 PortMessageType CodingAgentMessageType = "port" // for port monitoring events
Earl Lee2e463fb2025-04-17 11:22:22 -0700179
180 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
181)
182
183type AgentMessage struct {
184 Type CodingAgentMessageType `json:"type"`
185 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
186 EndOfTurn bool `json:"end_of_turn"`
187
188 Content string `json:"content"`
189 ToolName string `json:"tool_name,omitempty"`
190 ToolInput string `json:"input,omitempty"`
191 ToolResult string `json:"tool_result,omitempty"`
192 ToolError bool `json:"tool_error,omitempty"`
193 ToolCallId string `json:"tool_call_id,omitempty"`
194
195 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
196 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
197
Sean McCulloughd9f13372025-04-21 15:08:49 -0700198 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
199 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
200
Earl Lee2e463fb2025-04-17 11:22:22 -0700201 // Commits is a list of git commits for a commit message
202 Commits []*GitCommit `json:"commits,omitempty"`
203
204 Timestamp time.Time `json:"timestamp"`
205 ConversationID string `json:"conversation_id"`
206 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700207 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700208
209 // Message timing information
210 StartTime *time.Time `json:"start_time,omitempty"`
211 EndTime *time.Time `json:"end_time,omitempty"`
212 Elapsed *time.Duration `json:"elapsed,omitempty"`
213
214 // Turn duration - the time taken for a complete agent turn
215 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
216
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000217 // HideOutput indicates that this message should not be rendered in the UI.
218 // This is useful for subconversations that generate output that shouldn't be shown to the user.
219 HideOutput bool `json:"hide_output,omitempty"`
220
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700221 // TodoContent contains the agent's todo file content when it has changed
222 TodoContent *string `json:"todo_content,omitempty"`
223
Earl Lee2e463fb2025-04-17 11:22:22 -0700224 Idx int `json:"idx"`
225}
226
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000227// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700228func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700229 if convo == nil {
230 m.ConversationID = ""
231 m.ParentConversationID = nil
232 return
233 }
234 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000235 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700236 if convo.Parent != nil {
237 m.ParentConversationID = &convo.Parent.ID
238 }
239}
240
Earl Lee2e463fb2025-04-17 11:22:22 -0700241// GitCommit represents a single git commit for a commit message
242type GitCommit struct {
243 Hash string `json:"hash"` // Full commit hash
244 Subject string `json:"subject"` // Commit subject line
245 Body string `json:"body"` // Full commit message body
246 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
247}
248
249// ToolCall represents a single tool call within an agent message
250type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700251 Name string `json:"name"`
252 Input string `json:"input"`
253 ToolCallId string `json:"tool_call_id"`
254 ResultMessage *AgentMessage `json:"result_message,omitempty"`
255 Args string `json:"args,omitempty"`
256 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700257}
258
259func (a *AgentMessage) Attr() slog.Attr {
260 var attrs []any = []any{
261 slog.String("type", string(a.Type)),
262 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700263 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700264 if a.EndOfTurn {
265 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
266 }
267 if a.Content != "" {
268 attrs = append(attrs, slog.String("content", a.Content))
269 }
270 if a.ToolName != "" {
271 attrs = append(attrs, slog.String("tool_name", a.ToolName))
272 }
273 if a.ToolInput != "" {
274 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
275 }
276 if a.Elapsed != nil {
277 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
278 }
279 if a.TurnDuration != nil {
280 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
281 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700282 if len(a.ToolResult) > 0 {
283 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700284 }
285 if a.ToolError {
286 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
287 }
288 if len(a.ToolCalls) > 0 {
289 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
290 for i, tc := range a.ToolCalls {
291 toolCallAttrs = append(toolCallAttrs, slog.Group(
292 fmt.Sprintf("tool_call_%d", i),
293 slog.String("name", tc.Name),
294 slog.String("input", tc.Input),
295 ))
296 }
297 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
298 }
299 if a.ConversationID != "" {
300 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
301 }
302 if a.ParentConversationID != nil {
303 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
304 }
305 if a.Usage != nil && !a.Usage.IsZero() {
306 attrs = append(attrs, a.Usage.Attr())
307 }
308 // TODO: timestamp, convo ids, idx?
309 return slog.Group("agent_message", attrs...)
310}
311
312func errorMessage(err error) AgentMessage {
313 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
314 if os.Getenv(("DEBUG")) == "1" {
315 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
316 }
317
318 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
319}
320
321func budgetMessage(err error) AgentMessage {
322 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
323}
324
325// ConvoInterface defines the interface for conversation interactions
326type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700327 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700328 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700329 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700330 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700331 SendMessage(message llm.Message) (*llm.Response, error)
332 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700333 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000334 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700335 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700336 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700337 SubConvoWithHistory() *conversation.Convo
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700338 DebugJSON() ([]byte, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700339}
340
Philip Zeyligerf2872992025-05-22 10:35:28 -0700341// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700342// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700343// any time we notice we need to.
344type AgentGitState struct {
345 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700346 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700347 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000348 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700349 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700350 slug string // Human-readable session identifier
351 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700352 linesAdded int // Lines added from sketch-base to HEAD
353 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700354}
355
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700356func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700357 ags.mu.Lock()
358 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700359 if ags.slug != slug {
360 ags.retryNumber = 0
361 }
362 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700363}
364
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700365func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700366 ags.mu.Lock()
367 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700368 return ags.slug
369}
370
371func (ags *AgentGitState) IncrementRetryNumber() {
372 ags.mu.Lock()
373 defer ags.mu.Unlock()
374 ags.retryNumber++
375}
376
Philip Zeyliger64f60462025-06-16 13:57:10 -0700377func (ags *AgentGitState) DiffStats() (int, int) {
378 ags.mu.Lock()
379 defer ags.mu.Unlock()
380 return ags.linesAdded, ags.linesRemoved
381}
382
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700383// HasSeenCommits returns true if any commits have been processed
384func (ags *AgentGitState) HasSeenCommits() bool {
385 ags.mu.Lock()
386 defer ags.mu.Unlock()
387 return len(ags.seenCommits) > 0
388}
389
390func (ags *AgentGitState) RetryNumber() int {
391 ags.mu.Lock()
392 defer ags.mu.Unlock()
393 return ags.retryNumber
394}
395
396func (ags *AgentGitState) BranchName(prefix string) string {
397 ags.mu.Lock()
398 defer ags.mu.Unlock()
399 return ags.branchNameLocked(prefix)
400}
401
402func (ags *AgentGitState) branchNameLocked(prefix string) string {
403 if ags.slug == "" {
404 return ""
405 }
406 if ags.retryNumber == 0 {
407 return prefix + ags.slug
408 }
409 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700410}
411
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000412func (ags *AgentGitState) Upstream() string {
413 ags.mu.Lock()
414 defer ags.mu.Unlock()
415 return ags.upstream
416}
417
Earl Lee2e463fb2025-04-17 11:22:22 -0700418type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700419 convo ConvoInterface
420 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700421 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700422 workingDir string
423 repoRoot string // workingDir may be a subdir of repoRoot
424 url string
425 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000426 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700427 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000428 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700429 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700430 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000431 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700432 // State machine to track agent state
433 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000434 // Outside information
435 outsideHostname string
436 outsideOS string
437 outsideWorkingDir string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700438 // MCP manager for handling MCP server connections
439 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000440 // Port monitor for tracking TCP ports
441 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700442
443 // Time when the current turn started (reset at the beginning of InnerLoop)
444 startOfTurn time.Time
445
446 // Inbox - for messages from the user to the agent.
447 // sent on by UserMessage
448 // . e.g. when user types into the chat textarea
449 // read from by GatherMessages
450 inbox chan string
451
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000452 // protects cancelTurn
453 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700454 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000455 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700456
457 // protects following
458 mu sync.Mutex
459
460 // Stores all messages for this agent
461 history []AgentMessage
462
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700463 // Iterators add themselves here when they're ready to be notified of new messages.
464 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700465
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000466 // Track outstanding LLM call IDs
467 outstandingLLMCalls map[string]struct{}
468
469 // Track outstanding tool calls by ID with their names
470 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700471}
472
banksean5ab8fb82025-07-09 12:34:55 -0700473// TokenContextWindow implements CodingAgent.
474func (a *Agent) TokenContextWindow() int {
475 return a.config.Service.TokenContextWindow()
476}
477
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700478// GetConvo returns the conversation interface for debugging purposes.
479func (a *Agent) GetConvo() ConvoInterface {
480 return a.convo
481}
482
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700483// NewIterator implements CodingAgent.
484func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
485 a.mu.Lock()
486 defer a.mu.Unlock()
487
488 return &MessageIteratorImpl{
489 agent: a,
490 ctx: ctx,
491 nextMessageIdx: nextMessageIdx,
492 ch: make(chan *AgentMessage, 100),
493 }
494}
495
496type MessageIteratorImpl struct {
497 agent *Agent
498 ctx context.Context
499 nextMessageIdx int
500 ch chan *AgentMessage
501 subscribed bool
502}
503
504func (m *MessageIteratorImpl) Close() {
505 m.agent.mu.Lock()
506 defer m.agent.mu.Unlock()
507 // Delete ourselves from the subscribers list
508 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
509 return x == m.ch
510 })
511 close(m.ch)
512}
513
514func (m *MessageIteratorImpl) Next() *AgentMessage {
515 // We avoid subscription at creation to let ourselves catch up to "current state"
516 // before subscribing.
517 if !m.subscribed {
518 m.agent.mu.Lock()
519 if m.nextMessageIdx < len(m.agent.history) {
520 msg := &m.agent.history[m.nextMessageIdx]
521 m.nextMessageIdx++
522 m.agent.mu.Unlock()
523 return msg
524 }
525 // The next message doesn't exist yet, so let's subscribe
526 m.agent.subscribers = append(m.agent.subscribers, m.ch)
527 m.subscribed = true
528 m.agent.mu.Unlock()
529 }
530
531 for {
532 select {
533 case <-m.ctx.Done():
534 m.agent.mu.Lock()
535 // Delete ourselves from the subscribers list
536 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
537 return x == m.ch
538 })
539 m.subscribed = false
540 m.agent.mu.Unlock()
541 return nil
542 case msg, ok := <-m.ch:
543 if !ok {
544 // Close may have been called
545 return nil
546 }
547 if msg.Idx == m.nextMessageIdx {
548 m.nextMessageIdx++
549 return msg
550 }
551 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
552 panic("out of order message")
553 }
554 }
555}
556
Sean McCulloughd9d45812025-04-30 16:53:41 -0700557// Assert that Agent satisfies the CodingAgent interface.
558var _ CodingAgent = &Agent{}
559
560// StateName implements CodingAgent.
561func (a *Agent) CurrentStateName() string {
562 if a.stateMachine == nil {
563 return ""
564 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000565 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700566}
567
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700568// CurrentTodoContent returns the current todo list data as JSON.
569// It returns an empty string if no todos exist.
570func (a *Agent) CurrentTodoContent() string {
571 todoPath := claudetool.TodoFilePath(a.config.SessionID)
572 content, err := os.ReadFile(todoPath)
573 if err != nil {
574 return ""
575 }
576 return string(content)
577}
578
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700579// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
580func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
581 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.
582
583IMPORTANT: 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.
584
585Please create a detailed summary that includes:
586
5871. **User's Request**: What did the user originally ask me to do? What was their goal?
588
5892. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
590
5913. **Key Technical Decisions**: What important technical choices were made during our work and why?
592
5934. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
594
5955. **Next Steps**: What still needs to be done to complete the user's request?
596
5976. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
598
599Focus 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.
600
601Reply with ONLY the summary content - no meta-commentary about creating the summary.`
602
603 userMessage := llm.UserStringMessage(msg)
604 // Use a subconversation with history to get the summary
605 // TODO: We don't have any tools here, so we should have enough tokens
606 // to capture a summary, but we may need to modify the history (e.g., remove
607 // TODO data) to save on some tokens.
608 convo := a.convo.SubConvoWithHistory()
609
610 // Modify the system prompt to provide context about the original task
611 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000612 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 -0700613
614Your 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.
615
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000616Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700617
618 resp, err := convo.SendMessage(userMessage)
619 if err != nil {
620 a.pushToOutbox(ctx, errorMessage(err))
621 return "", err
622 }
623 textContent := collectTextContent(resp)
624
625 // Restore original system prompt (though this subconvo will be discarded)
626 convo.SystemPrompt = originalSystemPrompt
627
628 return textContent, nil
629}
630
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000631// dumpMessageHistoryToTmp dumps the agent's entire message history to /tmp as JSON
632// and returns the filename
633func (a *Agent) dumpMessageHistoryToTmp(ctx context.Context) (string, error) {
634 // Create a filename based on session ID and timestamp
635 timestamp := time.Now().Format("20060102-150405")
636 filename := fmt.Sprintf("/tmp/sketch-messages-%s-%s.json", a.config.SessionID, timestamp)
637
638 // Marshal the entire message history to JSON
639 jsonData, err := json.MarshalIndent(a.history, "", " ")
640 if err != nil {
641 return "", fmt.Errorf("failed to marshal message history: %w", err)
642 }
643
644 // Write to file
Autoformatter3ad8c8d2025-07-15 21:05:23 +0000645 if err := os.WriteFile(filename, jsonData, 0o644); err != nil {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000646 return "", fmt.Errorf("failed to write message history to %s: %w", filename, err)
647 }
648
649 slog.InfoContext(ctx, "Dumped message history to file", "filename", filename, "message_count", len(a.history))
650 return filename, nil
651}
652
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700653// CompactConversation compacts the current conversation by generating a summary
654// and restarting the conversation with that summary as the initial context
655func (a *Agent) CompactConversation(ctx context.Context) error {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000656 // Dump the entire message history to /tmp as JSON before compacting
657 dumpFile, err := a.dumpMessageHistoryToTmp(ctx)
658 if err != nil {
659 slog.WarnContext(ctx, "Failed to dump message history to /tmp", "error", err)
660 // Continue with compaction even if dump fails
661 }
662
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700663 summary, err := a.generateConversationSummary(ctx)
664 if err != nil {
665 return fmt.Errorf("failed to generate conversation summary: %w", err)
666 }
667
668 a.mu.Lock()
669
670 // Get usage information before resetting conversation
671 lastUsage := a.convo.LastUsage()
672 contextWindow := a.config.Service.TokenContextWindow()
673 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
674
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000675 // Preserve cumulative usage across compaction
676 cumulativeUsage := a.convo.CumulativeUsage()
677
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700678 // Reset conversation state but keep all other state (git, working dir, etc.)
679 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000680 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700681
682 a.mu.Unlock()
683
684 // Create informative compaction message with token details
685 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
686 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
687 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
688
689 a.pushToOutbox(ctx, AgentMessage{
690 Type: CompactMessageType,
691 Content: compactionMsg,
692 })
693
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000694 // Create the message content with dump file information if available
695 var messageContent string
696 if dumpFile != "" {
697 messageContent = fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nThe complete message history has been dumped to %s for your reference if needed.\n\nPlease continue with the work based on this summary.", summary, dumpFile)
698 } else {
699 messageContent = fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nPlease continue with the work based on this summary.", summary)
700 }
701
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700702 a.pushToOutbox(ctx, AgentMessage{
703 Type: UserMessageType,
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000704 Content: messageContent,
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700705 })
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000706 a.inbox <- messageContent
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700707
708 return nil
709}
710
Earl Lee2e463fb2025-04-17 11:22:22 -0700711func (a *Agent) URL() string { return a.url }
712
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000713// GetPorts returns the cached list of open TCP ports.
714func (a *Agent) GetPorts() []portlist.Port {
715 if a.portMonitor == nil {
716 return nil
717 }
718 return a.portMonitor.GetPorts()
719}
720
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000721// BranchName returns the git branch name for the conversation.
722func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700723 return a.gitState.BranchName(a.config.BranchPrefix)
724}
725
726// Slug returns the slug identifier for this conversation.
727func (a *Agent) Slug() string {
728 return a.gitState.Slug()
729}
730
731// IncrementRetryNumber increments the retry number for branch naming conflicts
732func (a *Agent) IncrementRetryNumber() {
733 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000734}
735
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000736// OutstandingLLMCallCount returns the number of outstanding LLM calls.
737func (a *Agent) OutstandingLLMCallCount() int {
738 a.mu.Lock()
739 defer a.mu.Unlock()
740 return len(a.outstandingLLMCalls)
741}
742
743// OutstandingToolCalls returns the names of outstanding tool calls.
744func (a *Agent) OutstandingToolCalls() []string {
745 a.mu.Lock()
746 defer a.mu.Unlock()
747
748 tools := make([]string, 0, len(a.outstandingToolCalls))
749 for _, toolName := range a.outstandingToolCalls {
750 tools = append(tools, toolName)
751 }
752 return tools
753}
754
Earl Lee2e463fb2025-04-17 11:22:22 -0700755// OS returns the operating system of the client.
756func (a *Agent) OS() string {
757 return a.config.ClientGOOS
758}
759
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000760func (a *Agent) SessionID() string {
761 return a.config.SessionID
762}
763
philip.zeyliger8773e682025-06-11 21:36:21 -0700764// SSHConnectionString returns the SSH connection string for the container.
765func (a *Agent) SSHConnectionString() string {
766 return a.config.SSHConnectionString
767}
768
Philip Zeyliger18532b22025-04-23 21:11:46 +0000769// OutsideOS returns the operating system of the outside system.
770func (a *Agent) OutsideOS() string {
771 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000772}
773
Philip Zeyliger18532b22025-04-23 21:11:46 +0000774// OutsideHostname returns the hostname of the outside system.
775func (a *Agent) OutsideHostname() string {
776 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000777}
778
Philip Zeyliger18532b22025-04-23 21:11:46 +0000779// OutsideWorkingDir returns the working directory on the outside system.
780func (a *Agent) OutsideWorkingDir() string {
781 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000782}
783
784// GitOrigin returns the URL of the git remote 'origin' if it exists.
785func (a *Agent) GitOrigin() string {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000786 return a.config.OriginalGitOrigin
Philip Zeyligerd1402952025-04-23 03:54:37 +0000787}
788
bankseancad67b02025-06-27 21:57:05 +0000789// GitUsername returns the git user name from the agent config.
790func (a *Agent) GitUsername() string {
791 return a.config.GitUsername
792}
793
Philip Zeyliger64f60462025-06-16 13:57:10 -0700794// DiffStats returns the number of lines added and removed from sketch-base to HEAD
795func (a *Agent) DiffStats() (int, int) {
796 return a.gitState.DiffStats()
797}
798
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000799func (a *Agent) OpenBrowser(url string) {
800 if !a.IsInContainer() {
801 browser.Open(url)
802 return
803 }
804 // We're in Docker, need to send a request to the Git server
805 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700806 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000807 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700808 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000809 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700810 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000811 return
812 }
813 defer resp.Body.Close()
814 if resp.StatusCode == http.StatusOK {
815 return
816 }
817 body, _ := io.ReadAll(resp.Body)
818 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
819}
820
Sean McCullough96b60dd2025-04-30 09:49:10 -0700821// CurrentState returns the current state of the agent's state machine.
822func (a *Agent) CurrentState() State {
823 return a.stateMachine.CurrentState()
824}
825
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700826func (a *Agent) IsInContainer() bool {
827 return a.config.InDocker
828}
829
830func (a *Agent) FirstMessageIndex() int {
831 a.mu.Lock()
832 defer a.mu.Unlock()
833 return a.firstMessageIndex
834}
835
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700836// SetSlug sets a human-readable identifier for the conversation.
837func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700838 a.mu.Lock()
839 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700840
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700841 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000842 convo, ok := a.convo.(*conversation.Convo)
843 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700844 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000845 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700846}
847
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000848// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700849func (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 +0000850 // Track the tool call
851 a.mu.Lock()
852 a.outstandingToolCalls[id] = toolName
853 a.mu.Unlock()
854}
855
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700856// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
857// If there's only one element in the array and it's a text type, it returns that text directly.
858// It also processes nested ToolResult arrays recursively.
859func contentToString(contents []llm.Content) string {
860 if len(contents) == 0 {
861 return ""
862 }
863
864 // If there's only one element and it's a text type, return it directly
865 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
866 return contents[0].Text
867 }
868
869 // Otherwise, concatenate all text content
870 var result strings.Builder
871 for _, content := range contents {
872 if content.Type == llm.ContentTypeText {
873 result.WriteString(content.Text)
874 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
875 // Recursively process nested tool results
876 result.WriteString(contentToString(content.ToolResult))
877 }
878 }
879
880 return result.String()
881}
882
Earl Lee2e463fb2025-04-17 11:22:22 -0700883// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700884func (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 +0000885 // Remove the tool call from outstanding calls
886 a.mu.Lock()
887 delete(a.outstandingToolCalls, toolID)
888 a.mu.Unlock()
889
Earl Lee2e463fb2025-04-17 11:22:22 -0700890 m := AgentMessage{
891 Type: ToolUseMessageType,
892 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700893 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700894 ToolError: content.ToolError,
895 ToolName: toolName,
896 ToolInput: string(toolInput),
897 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700898 StartTime: content.ToolUseStartTime,
899 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700900 }
901
902 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700903 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
904 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700905 m.Elapsed = &elapsed
906 }
907
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700908 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700909 a.pushToOutbox(ctx, m)
910}
911
912// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700913func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000914 a.mu.Lock()
915 defer a.mu.Unlock()
916 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700917 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
918}
919
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700920// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700921// that need to be displayed (as well as tool calls that we send along when
922// they're done). (It would be reasonable to also mention tool calls when they're
923// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700924func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000925 // Remove the LLM call from outstanding calls
926 a.mu.Lock()
927 delete(a.outstandingLLMCalls, id)
928 a.mu.Unlock()
929
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700930 if resp == nil {
931 // LLM API call failed
932 m := AgentMessage{
933 Type: ErrorMessageType,
934 Content: "API call failed, type 'continue' to try again",
935 }
936 m.SetConvo(convo)
937 a.pushToOutbox(ctx, m)
938 return
939 }
940
Earl Lee2e463fb2025-04-17 11:22:22 -0700941 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700942 if convo.Parent == nil { // subconvos never end the turn
943 switch resp.StopReason {
944 case llm.StopReasonToolUse:
945 // Check whether any of the tool calls are for tools that should end the turn
946 ToolSearch:
947 for _, part := range resp.Content {
948 if part.Type != llm.ContentTypeToolUse {
949 continue
950 }
Sean McCullough021557a2025-05-05 23:20:53 +0000951 // Find the tool by name
952 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700953 if tool.Name == part.ToolName {
954 endOfTurn = tool.EndsTurn
955 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000956 }
957 }
Sean McCullough021557a2025-05-05 23:20:53 +0000958 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700959 default:
960 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000961 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700962 }
963 m := AgentMessage{
964 Type: AgentMessageType,
965 Content: collectTextContent(resp),
966 EndOfTurn: endOfTurn,
967 Usage: &resp.Usage,
968 StartTime: resp.StartTime,
969 EndTime: resp.EndTime,
970 }
971
972 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700973 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700974 var toolCalls []ToolCall
975 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700976 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700977 toolCalls = append(toolCalls, ToolCall{
978 Name: part.ToolName,
979 Input: string(part.ToolInput),
980 ToolCallId: part.ID,
981 })
982 }
983 }
984 m.ToolCalls = toolCalls
985 }
986
987 // Calculate the elapsed time if both start and end times are set
988 if resp.StartTime != nil && resp.EndTime != nil {
989 elapsed := resp.EndTime.Sub(*resp.StartTime)
990 m.Elapsed = &elapsed
991 }
992
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700993 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700994 a.pushToOutbox(ctx, m)
995}
996
997// WorkingDir implements CodingAgent.
998func (a *Agent) WorkingDir() string {
999 return a.workingDir
1000}
1001
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001002// RepoRoot returns the git repository root directory.
1003func (a *Agent) RepoRoot() string {
1004 return a.repoRoot
1005}
1006
Earl Lee2e463fb2025-04-17 11:22:22 -07001007// MessageCount implements CodingAgent.
1008func (a *Agent) MessageCount() int {
1009 a.mu.Lock()
1010 defer a.mu.Unlock()
1011 return len(a.history)
1012}
1013
1014// Messages implements CodingAgent.
1015func (a *Agent) Messages(start int, end int) []AgentMessage {
1016 a.mu.Lock()
1017 defer a.mu.Unlock()
1018 return slices.Clone(a.history[start:end])
1019}
1020
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001021// ShouldCompact checks if the conversation should be compacted based on token usage
1022func (a *Agent) ShouldCompact() bool {
1023 // Get the threshold from environment variable, default to 0.94 (94%)
1024 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
1025 // and a little bit of buffer.)
1026 thresholdRatio := 0.94
1027 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
1028 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
1029 thresholdRatio = parsed
1030 }
1031 }
1032
1033 // Get the most recent usage to check current context size
1034 lastUsage := a.convo.LastUsage()
1035
1036 if lastUsage.InputTokens == 0 {
1037 // No API calls made yet
1038 return false
1039 }
1040
1041 // Calculate the current context size from the last API call
1042 // This includes all tokens that were part of the input context:
1043 // - Input tokens (user messages, system prompt, conversation history)
1044 // - Cache read tokens (cached parts of the context)
1045 // - Cache creation tokens (new parts being cached)
1046 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1047
1048 // Get the service's token context window
1049 service := a.config.Service
1050 contextWindow := service.TokenContextWindow()
1051
1052 // Calculate threshold
1053 threshold := uint64(float64(contextWindow) * thresholdRatio)
1054
1055 // Check if we've exceeded the threshold
1056 return currentContextSize >= threshold
1057}
1058
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001059func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001060 return a.originalBudget
1061}
1062
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001063// Upstream returns the upstream branch for git work
1064func (a *Agent) Upstream() string {
1065 return a.gitState.Upstream()
1066}
1067
Earl Lee2e463fb2025-04-17 11:22:22 -07001068// AgentConfig contains configuration for creating a new Agent.
1069type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001070 Context context.Context
1071 Service llm.Service
1072 Budget conversation.Budget
1073 GitUsername string
1074 GitEmail string
1075 SessionID string
1076 ClientGOOS string
1077 ClientGOARCH string
1078 InDocker bool
1079 OneShot bool
1080 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001081 // Outside information
1082 OutsideHostname string
1083 OutsideOS string
1084 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001085
1086 // Outtie's HTTP to, e.g., open a browser
1087 OutsideHTTP string
1088 // Outtie's Git server
1089 GitRemoteAddr string
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001090 // Original git origin URL from host repository, if any
1091 OriginalGitOrigin string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001092 // Upstream branch for git work
1093 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001094 // Commit to checkout from Outtie
1095 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001096 // Prefix for git branches created by sketch
1097 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001098 // LinkToGitHub enables GitHub branch linking in UI
1099 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001100 // SSH connection string for connecting to the container
1101 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001102 // Skaband client for session history (optional)
1103 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001104 // MCP server configurations
1105 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001106 // Timeout configuration for bash tool
1107 BashTimeouts *claudetool.Timeouts
Earl Lee2e463fb2025-04-17 11:22:22 -07001108}
1109
1110// NewAgent creates a new Agent.
1111// It is not usable until Init() is called.
1112func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001113 // Set default branch prefix if not specified
1114 if config.BranchPrefix == "" {
1115 config.BranchPrefix = "sketch/"
1116 }
1117
Earl Lee2e463fb2025-04-17 11:22:22 -07001118 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001119 config: config,
1120 ready: make(chan struct{}),
1121 inbox: make(chan string, 100),
1122 subscribers: make([]chan *AgentMessage, 0),
1123 startedAt: time.Now(),
1124 originalBudget: config.Budget,
1125 gitState: AgentGitState{
1126 seenCommits: make(map[string]bool),
1127 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001128 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001129 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001130 outsideHostname: config.OutsideHostname,
1131 outsideOS: config.OutsideOS,
1132 outsideWorkingDir: config.OutsideWorkingDir,
1133 outstandingLLMCalls: make(map[string]struct{}),
1134 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001135 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001136 workingDir: config.WorkingDir,
1137 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001138
1139 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001140 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001141
1142 // Initialize port monitor with 5-second interval
1143 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1144
Earl Lee2e463fb2025-04-17 11:22:22 -07001145 return agent
1146}
1147
1148type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001149 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001150
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001151 InDocker bool
1152 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001153}
1154
1155func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001156 if a.convo != nil {
1157 return fmt.Errorf("Agent.Init: already initialized")
1158 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001159 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001160 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001161
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001162 // If a remote + commit was specified, clone it.
1163 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00001164 if _, err := os.Stat("/app/.git"); err == nil {
1165 // Already a repo in /app.
1166 // Make sure that the remote is configured correctly.
1167 // We do a fetch below.
1168 if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil {
1169 return err
1170 }
1171 } else {
1172 slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
1173 // TODO: --reference-if-able instead?
1174 cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
1175 if out, err := cmd.CombinedOutput(); err != nil {
1176 return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err)
1177 }
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001178 }
1179 }
1180
1181 if a.workingDir != "" {
1182 err := os.Chdir(a.workingDir)
1183 if err != nil {
1184 return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err)
1185 }
1186 }
1187
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001188 if !ini.NoGit {
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001189
1190 // Configure git user settings
1191 if a.config.GitEmail != "" {
1192 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1193 cmd.Dir = a.workingDir
1194 if out, err := cmd.CombinedOutput(); err != nil {
1195 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1196 }
1197 }
1198 if a.config.GitUsername != "" {
1199 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1200 cmd.Dir = a.workingDir
1201 if out, err := cmd.CombinedOutput(); err != nil {
1202 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1203 }
1204 }
1205 // Configure git http.postBuffer
1206 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1207 cmd.Dir = a.workingDir
1208 if out, err := cmd.CombinedOutput(); err != nil {
1209 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1210 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001211 }
1212
Philip Zeyligerf2872992025-05-22 10:35:28 -07001213 // If a commit was specified, we fetch and reset to it.
1214 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001215 slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit)
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001216
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001217 cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001218 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001219 if out, err := cmd.CombinedOutput(); err != nil {
1220 return fmt.Errorf("git fetch: %s: %w", out, err)
1221 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001222 // The -B resets the branch if it already exists (or creates it if it doesn't)
1223 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001224 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001225 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1226 // Remove git hooks if they exist and retry
1227 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001228 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001229 if _, statErr := os.Stat(hookPath); statErr == nil {
1230 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1231 slog.String("error", err.Error()),
1232 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001233 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001234 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1235 }
1236
1237 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001238 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001239 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001240 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001241 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 +01001242 }
1243 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001244 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001245 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001246 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001247 } else if a.IsInContainer() {
1248 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1249 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1250 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1251 cmd.Dir = a.workingDir
1252 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1253 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1254 }
1255 } else {
1256 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001257 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001258
1259 if ini.HostAddr != "" {
1260 a.url = "http://" + ini.HostAddr
1261 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001262
1263 if !ini.NoGit {
1264 repoRoot, err := repoRoot(ctx, a.workingDir)
1265 if err != nil {
1266 return fmt.Errorf("repoRoot: %w", err)
1267 }
1268 a.repoRoot = repoRoot
1269
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001270 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001271 if err := setupGitHooks(a.repoRoot); err != nil {
1272 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1273 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001274 }
1275
philz24613202025-07-15 20:56:21 -07001276 // Check if we have any commits, and if not, create an empty initial commit
1277 cmd := exec.CommandContext(ctx, "git", "rev-list", "--all", "--count")
1278 cmd.Dir = repoRoot
1279 countOut, err := cmd.CombinedOutput()
1280 if err != nil {
1281 return fmt.Errorf("git rev-list --all --count: %s: %w", countOut, err)
1282 }
1283 commitCount := strings.TrimSpace(string(countOut))
1284 if commitCount == "0" {
1285 slog.Info("No commits found, creating empty initial commit")
1286 cmd = exec.CommandContext(ctx, "git", "commit", "--allow-empty", "-m", "Initial empty commit")
1287 cmd.Dir = repoRoot
1288 if commitOut, err := cmd.CombinedOutput(); err != nil {
1289 return fmt.Errorf("git commit --allow-empty: %s: %w", commitOut, err)
1290 }
1291 }
1292
1293 cmd = exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
Philip Zeyliger49edc922025-05-14 09:45:45 -07001294 cmd.Dir = repoRoot
1295 if out, err := cmd.CombinedOutput(); err != nil {
1296 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1297 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001298
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001299 slog.Info("running codebase analysis")
1300 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1301 if err != nil {
1302 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001303 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001304 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001305
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001306 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001307 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001308 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001309 }
1310 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001311
Earl Lee2e463fb2025-04-17 11:22:22 -07001312 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001313 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001314 a.convo = a.initConvo()
1315 close(a.ready)
1316 return nil
1317}
1318
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001319//go:embed agent_system_prompt.txt
1320var agentSystemPrompt string
1321
Earl Lee2e463fb2025-04-17 11:22:22 -07001322// initConvo initializes the conversation.
1323// It must not be called until all agent fields are initialized,
1324// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001325func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001326 return a.initConvoWithUsage(nil)
1327}
1328
1329// initConvoWithUsage initializes the conversation with optional preserved usage.
1330func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001331 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001332 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001333 convo.PromptCaching = true
1334 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001335 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001336 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001337
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001338 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1339 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001340 if a.gitState.Slug() != "" {
1341 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001342 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001343 willCommit, err := bashkit.WillRunGitCommit(command)
1344 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001345 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001346 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001347 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001348 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001349 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001350 return nil
1351 }
1352
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001353 bashTool := &claudetool.BashTool{
1354 CheckPermission: bashPermissionCheck,
1355 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1356 Timeouts: a.config.BashTimeouts,
1357 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001358
Earl Lee2e463fb2025-04-17 11:22:22 -07001359 // Register all tools with the conversation
1360 // When adding, removing, or modifying tools here, double-check that the termui tool display
1361 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001362
1363 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001364 _, supportsScreenshots := a.config.Service.(*ant.Service)
1365 var bTools []*llm.Tool
1366 var browserCleanup func()
1367
1368 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1369 // Add cleanup function to context cancel
1370 go func() {
1371 <-a.config.Context.Done()
1372 browserCleanup()
1373 }()
1374 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001375
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001376 convo.Tools = []*llm.Tool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001377 bashTool.Tool(), claudetool.Keyword, claudetool.Patch(a.patchCallback),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001378 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001379 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001380 }
1381
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001382 // One-shot mode is non-interactive, multiple choice requires human response
1383 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001384 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001385 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001386
1387 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001388
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001389 // Add MCP tools if configured
1390 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001391
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001392 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001393 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1394
1395 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1396 for i := range serverConfigs {
1397 if serverConfigs[i].Headers != nil {
1398 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001399 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1400 if strings.HasPrefix(value, "env:") {
1401 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001402 }
1403 }
1404 }
1405 }
1406 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, 10*time.Second, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001407
1408 if len(mcpErrors) > 0 {
1409 for _, err := range mcpErrors {
1410 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1411 // Send agent message about MCP connection failures
1412 a.pushToOutbox(ctx, AgentMessage{
1413 Type: ErrorMessageType,
1414 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1415 })
1416 }
1417 }
1418
1419 if len(mcpConnections) > 0 {
1420 // Add tools from all successful connections
1421 totalTools := 0
1422 for _, connection := range mcpConnections {
1423 convo.Tools = append(convo.Tools, connection.Tools...)
1424 totalTools += len(connection.Tools)
1425 // Log tools per server using structured data
1426 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1427 }
1428 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1429 } else {
1430 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1431 }
1432 }
1433
Earl Lee2e463fb2025-04-17 11:22:22 -07001434 convo.Listener = a
1435 return convo
1436}
1437
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001438var multipleChoiceTool = &llm.Tool{
1439 Name: "multiplechoice",
1440 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.",
1441 EndsTurn: true,
1442 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001443 "type": "object",
1444 "description": "The question and a list of answers you would expect the user to choose from.",
1445 "properties": {
1446 "question": {
1447 "type": "string",
1448 "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?'"
1449 },
1450 "responseOptions": {
1451 "type": "array",
1452 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1453 "items": {
1454 "type": "object",
1455 "properties": {
1456 "caption": {
1457 "type": "string",
1458 "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'"
1459 },
1460 "responseText": {
1461 "type": "string",
1462 "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'"
1463 }
1464 },
1465 "required": ["caption", "responseText"]
1466 }
1467 }
1468 },
1469 "required": ["question", "responseOptions"]
1470}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001471 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1472 // The Run logic for "multiplechoice" tool is a no-op on the server.
1473 // The UI will present a list of options for the user to select from,
1474 // and that's it as far as "executing" the tool_use goes.
1475 // When the user *does* select one of the presented options, that
1476 // responseText gets sent as a chat message on behalf of the user.
1477 return llm.TextContent("end your turn and wait for the user to respond"), nil
1478 },
Sean McCullough485afc62025-04-28 14:28:39 -07001479}
1480
1481type MultipleChoiceOption struct {
1482 Caption string `json:"caption"`
1483 ResponseText string `json:"responseText"`
1484}
1485
1486type MultipleChoiceParams struct {
1487 Question string `json:"question"`
1488 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1489}
1490
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001491// branchExists reports whether branchName exists, either locally or in well-known remotes.
1492func branchExists(dir, branchName string) bool {
1493 refs := []string{
1494 "refs/heads/",
1495 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001496 }
1497 for _, ref := range refs {
1498 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1499 cmd.Dir = dir
1500 if cmd.Run() == nil { // exit code 0 means branch exists
1501 return true
1502 }
1503 }
1504 return false
1505}
1506
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001507func (a *Agent) setSlugTool() *llm.Tool {
1508 return &llm.Tool{
1509 Name: "set-slug",
1510 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001511 InputSchema: json.RawMessage(`{
1512 "type": "object",
1513 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001514 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001515 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001516 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001517 }
1518 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001519 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001520}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001521 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001522 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001523 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001524 }
1525 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001526 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001527 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001528 // Prevent slug changes if there have been git changes
1529 // This lets the agent change its mind about a good slug,
1530 // while ensuring that once a branch has been pushed, it remains stable.
1531 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1532 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001533 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001534 if params.Slug == "" {
1535 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001536 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001537 slug := cleanSlugName(params.Slug)
1538 if slug == "" {
1539 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1540 }
1541 a.SetSlug(slug)
1542 // TODO: do this by a call to outie, rather than semi-guessing from innie
1543 if branchExists(a.workingDir, a.BranchName()) {
1544 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1545 }
1546 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001547 },
1548 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001549}
1550
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001551func (a *Agent) commitMessageStyleTool() *llm.Tool {
1552 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 +00001553 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001554 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001555 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001556 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001557 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001558 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1559 if err != nil {
1560 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1561 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001562 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001563 },
1564 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001565 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001566}
1567
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001568// patchCallback is the agent's patch tool callback.
1569// It warms the codereview cache in the background.
1570func (a *Agent) patchCallback(input claudetool.PatchInput, result []llm.Content, err error) ([]llm.Content, error) {
1571 if a.codereview != nil {
1572 a.codereview.WarmTestCache(input.Path)
1573 }
1574 return result, err
1575}
1576
Earl Lee2e463fb2025-04-17 11:22:22 -07001577func (a *Agent) Ready() <-chan struct{} {
1578 return a.ready
1579}
1580
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001581// BranchPrefix returns the configured branch prefix
1582func (a *Agent) BranchPrefix() string {
1583 return a.config.BranchPrefix
1584}
1585
philip.zeyliger6d3de482025-06-10 19:38:14 -07001586// LinkToGitHub returns whether GitHub branch linking is enabled
1587func (a *Agent) LinkToGitHub() bool {
1588 return a.config.LinkToGitHub
1589}
1590
Earl Lee2e463fb2025-04-17 11:22:22 -07001591func (a *Agent) UserMessage(ctx context.Context, msg string) {
1592 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1593 a.inbox <- msg
1594}
1595
Earl Lee2e463fb2025-04-17 11:22:22 -07001596func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1597 return a.convo.CancelToolUse(toolUseID, cause)
1598}
1599
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001600func (a *Agent) CancelTurn(cause error) {
1601 a.cancelTurnMu.Lock()
1602 defer a.cancelTurnMu.Unlock()
1603 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001604 // Force state transition to cancelled state
1605 ctx := a.config.Context
1606 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001607 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001608 }
1609}
1610
1611func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001612 // Start port monitoring
1613 if a.portMonitor != nil && a.IsInContainer() {
1614 if err := a.portMonitor.Start(ctxOuter); err != nil {
1615 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1616 } else {
1617 slog.InfoContext(ctxOuter, "Port monitor started")
1618 }
1619 }
1620
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001621 // Set up cleanup when context is done
1622 defer func() {
1623 if a.mcpManager != nil {
1624 a.mcpManager.Close()
1625 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001626 if a.portMonitor != nil && a.IsInContainer() {
1627 a.portMonitor.Stop()
1628 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001629 }()
1630
Earl Lee2e463fb2025-04-17 11:22:22 -07001631 for {
1632 select {
1633 case <-ctxOuter.Done():
1634 return
1635 default:
1636 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001637 a.cancelTurnMu.Lock()
1638 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001639 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001640 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001641 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001642 a.cancelTurn = cancel
1643 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001644 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1645 if err != nil {
1646 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1647 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001648 cancel(nil)
1649 }
1650 }
1651}
1652
1653func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1654 if m.Timestamp.IsZero() {
1655 m.Timestamp = time.Now()
1656 }
1657
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001658 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1659 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1660 m.Content = m.ToolResult
1661 }
1662
Earl Lee2e463fb2025-04-17 11:22:22 -07001663 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1664 if m.EndOfTurn && m.Type == AgentMessageType {
1665 turnDuration := time.Since(a.startOfTurn)
1666 m.TurnDuration = &turnDuration
1667 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1668 }
1669
Earl Lee2e463fb2025-04-17 11:22:22 -07001670 a.mu.Lock()
1671 defer a.mu.Unlock()
1672 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001673 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001674 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001675
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001676 // Notify all subscribers
1677 for _, ch := range a.subscribers {
1678 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001679 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001680}
1681
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001682func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1683 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001684 if block {
1685 select {
1686 case <-ctx.Done():
1687 return m, ctx.Err()
1688 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001689 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001690 }
1691 }
1692 for {
1693 select {
1694 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001695 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001696 default:
1697 return m, nil
1698 }
1699 }
1700}
1701
Sean McCullough885a16a2025-04-30 02:49:25 +00001702// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001703func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001704 // Reset the start of turn time
1705 a.startOfTurn = time.Now()
1706
Sean McCullough96b60dd2025-04-30 09:49:10 -07001707 // Transition to waiting for user input state
1708 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1709
Sean McCullough885a16a2025-04-30 02:49:25 +00001710 // Process initial user message
1711 initialResp, err := a.processUserMessage(ctx)
1712 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001713 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001714 return err
1715 }
1716
1717 // Handle edge case where both initialResp and err are nil
1718 if initialResp == nil {
1719 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001720 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1721
Sean McCullough9f4b8082025-04-30 17:34:07 +00001722 a.pushToOutbox(ctx, errorMessage(err))
1723 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001724 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001725
Earl Lee2e463fb2025-04-17 11:22:22 -07001726 // We do this as we go, but let's also do it at the end of the turn
1727 defer func() {
1728 if _, err := a.handleGitCommits(ctx); err != nil {
1729 // Just log the error, don't stop execution
1730 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1731 }
1732 }()
1733
Sean McCullougha1e0e492025-05-01 10:51:08 -07001734 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001735 resp := initialResp
1736 for {
1737 // Check if we are over budget
1738 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001739 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001740 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001741 }
1742
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001743 // Check if we should compact the conversation
1744 if a.ShouldCompact() {
1745 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1746 if err := a.CompactConversation(ctx); err != nil {
1747 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1748 return err
1749 }
1750 // After compaction, end this turn and start fresh
1751 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1752 return nil
1753 }
1754
Sean McCullough885a16a2025-04-30 02:49:25 +00001755 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001756 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001757 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001758 break
1759 }
1760
Sean McCullough96b60dd2025-04-30 09:49:10 -07001761 // Transition to tool use requested state
1762 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1763
Sean McCullough885a16a2025-04-30 02:49:25 +00001764 // Handle tool execution
1765 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1766 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001767 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001768 }
1769
Sean McCullougha1e0e492025-05-01 10:51:08 -07001770 if toolResp == nil {
1771 return fmt.Errorf("cannot continue conversation with a nil tool response")
1772 }
1773
Sean McCullough885a16a2025-04-30 02:49:25 +00001774 // Set the response for the next iteration
1775 resp = toolResp
1776 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001777
1778 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001779}
1780
1781// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001782func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001783 // Wait for at least one message from the user
1784 msgs, err := a.GatherMessages(ctx, true)
1785 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001786 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001787 return nil, err
1788 }
1789
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001790 userMessage := llm.Message{
1791 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001792 Content: msgs,
1793 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001794
Sean McCullough96b60dd2025-04-30 09:49:10 -07001795 // Transition to sending to LLM state
1796 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1797
Sean McCullough885a16a2025-04-30 02:49:25 +00001798 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001799 resp, err := a.convo.SendMessage(userMessage)
1800 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001801 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001802 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001803 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001804 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001805
Sean McCullough96b60dd2025-04-30 09:49:10 -07001806 // Transition to processing LLM response state
1807 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1808
Sean McCullough885a16a2025-04-30 02:49:25 +00001809 return resp, nil
1810}
1811
1812// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001813func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1814 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001815 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001816 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001817
Sean McCullough96b60dd2025-04-30 09:49:10 -07001818 // Transition to checking for cancellation state
1819 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1820
Sean McCullough885a16a2025-04-30 02:49:25 +00001821 // Check if the operation was cancelled by the user
1822 select {
1823 case <-ctx.Done():
1824 // Don't actually run any of the tools, but rather build a response
1825 // for each tool_use message letting the LLM know that user canceled it.
1826 var err error
1827 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001828 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001829 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001830 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001831 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001832 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001833 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001834 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001835 // Transition to running tool state
1836 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1837
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001838 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001839 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001840 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001841
1842 // Execute the tools
1843 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001844 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001845 if ctx.Err() != nil { // e.g. the user canceled the operation
1846 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001847 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001848 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001849 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001850 a.pushToOutbox(ctx, errorMessage(err))
1851 }
1852 }
1853
1854 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001855 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001856 autoqualityMessages := a.processGitChanges(ctx)
1857
1858 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001859 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001860 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001861 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001862 return false, nil
1863 }
1864
1865 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001866 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1867 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001868}
1869
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001870// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001871func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001872 // Check for git commits
1873 _, err := a.handleGitCommits(ctx)
1874 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001875 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001876 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001877 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001878 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001879}
1880
1881// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1882// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001883func (a *Agent) processGitChanges(ctx context.Context) []string {
1884 // Check for git commits after tool execution
1885 newCommits, err := a.handleGitCommits(ctx)
1886 if err != nil {
1887 // Just log the error, don't stop execution
1888 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1889 return nil
1890 }
1891
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001892 // Run mechanical checks if there was exactly one new commit.
1893 if len(newCommits) != 1 {
1894 return nil
1895 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001896 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001897 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1898 msg := a.codereview.RunMechanicalChecks(ctx)
1899 if msg != "" {
1900 a.pushToOutbox(ctx, AgentMessage{
1901 Type: AutoMessageType,
1902 Content: msg,
1903 Timestamp: time.Now(),
1904 })
1905 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001906 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001907
1908 return autoqualityMessages
1909}
1910
1911// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001912func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001913 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001914 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001915 msgs, err := a.GatherMessages(ctx, false)
1916 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001917 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001918 return false, nil
1919 }
1920
1921 // Inject any auto-generated messages from quality checks
1922 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001923 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001924 }
1925
1926 // Handle cancellation by appending a message about it
1927 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001928 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001929 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001930 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001931 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1932 } else if err := a.convo.OverBudget(); err != nil {
1933 // Handle budget issues by appending a message about it
1934 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 -07001935 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001936 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1937 }
1938
1939 // Combine tool results with user messages
1940 results = append(results, msgs...)
1941
1942 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001943 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001944 resp, err := a.convo.SendMessage(llm.Message{
1945 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001946 Content: results,
1947 })
1948 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001949 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001950 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1951 return true, nil // Return true to continue the conversation, but with no response
1952 }
1953
Sean McCullough96b60dd2025-04-30 09:49:10 -07001954 // Transition back to processing LLM response
1955 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1956
Sean McCullough885a16a2025-04-30 02:49:25 +00001957 if cancelled {
1958 return false, nil
1959 }
1960
1961 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001962}
1963
1964func (a *Agent) overBudget(ctx context.Context) error {
1965 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001966 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001967 m := budgetMessage(err)
1968 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001969 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001970 a.convo.ResetBudget(a.originalBudget)
1971 return err
1972 }
1973 return nil
1974}
1975
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001976func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001977 // Collect all text content
1978 var allText strings.Builder
1979 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001980 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001981 if allText.Len() > 0 {
1982 allText.WriteString("\n\n")
1983 }
1984 allText.WriteString(content.Text)
1985 }
1986 }
1987 return allText.String()
1988}
1989
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001990func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001991 a.mu.Lock()
1992 defer a.mu.Unlock()
1993 return a.convo.CumulativeUsage()
1994}
1995
Earl Lee2e463fb2025-04-17 11:22:22 -07001996// Diff returns a unified diff of changes made since the agent was instantiated.
1997func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001998 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001999 return "", fmt.Errorf("no initial commit reference available")
2000 }
2001
2002 // Find the repository root
2003 ctx := context.Background()
2004
2005 // If a specific commit hash is provided, show just that commit's changes
2006 if commit != nil && *commit != "" {
2007 // Validate that the commit looks like a valid git SHA
2008 if !isValidGitSHA(*commit) {
2009 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
2010 }
2011
2012 // Get the diff for just this commit
2013 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
2014 cmd.Dir = a.repoRoot
2015 output, err := cmd.CombinedOutput()
2016 if err != nil {
2017 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
2018 }
2019 return string(output), nil
2020 }
2021
2022 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07002023 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07002024 cmd.Dir = a.repoRoot
2025 output, err := cmd.CombinedOutput()
2026 if err != nil {
2027 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
2028 }
2029
2030 return string(output), nil
2031}
2032
Philip Zeyliger49edc922025-05-14 09:45:45 -07002033// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
2034// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
2035func (a *Agent) SketchGitBaseRef() string {
2036 if a.IsInContainer() {
2037 return "sketch-base"
2038 } else {
2039 return "sketch-base-" + a.SessionID()
2040 }
2041}
2042
2043// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
2044func (a *Agent) SketchGitBase() string {
2045 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
2046 cmd.Dir = a.repoRoot
2047 output, err := cmd.CombinedOutput()
2048 if err != nil {
2049 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
2050 return "HEAD"
2051 }
2052 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002053}
2054
Pokey Rule7a113622025-05-12 10:58:45 +01002055// removeGitHooks removes the Git hooks directory from the repository
2056func removeGitHooks(_ context.Context, repoPath string) error {
2057 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2058
2059 // Check if hooks directory exists
2060 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2061 // Directory doesn't exist, nothing to do
2062 return nil
2063 }
2064
2065 // Remove the hooks directory
2066 err := os.RemoveAll(hooksDir)
2067 if err != nil {
2068 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2069 }
2070
2071 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002072 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002073 if err != nil {
2074 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2075 }
2076
2077 return nil
2078}
2079
Philip Zeyligerf2872992025-05-22 10:35:28 -07002080func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002081 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002082 for _, msg := range msgs {
2083 a.pushToOutbox(ctx, msg)
2084 }
2085 return commits, error
2086}
2087
Earl Lee2e463fb2025-04-17 11:22:22 -07002088// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002089// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002090func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002091 ags.mu.Lock()
2092 defer ags.mu.Unlock()
2093
2094 msgs := []AgentMessage{}
2095 if repoRoot == "" {
2096 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002097 }
2098
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002099 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002100 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002101 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002102 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002103 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002104 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002105 }
2106 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002107 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002108 }()
2109
Philip Zeyliger64f60462025-06-16 13:57:10 -07002110 // Compute diff stats from baseRef to HEAD when HEAD changes
2111 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2112 // Log error but don't fail the entire operation
2113 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2114 } else {
2115 // Set diff stats directly since we already hold the mutex
2116 ags.linesAdded = added
2117 ags.linesRemoved = removed
2118 }
2119
Earl Lee2e463fb2025-04-17 11:22:22 -07002120 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2121 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2122 // to the last 100 commits.
2123 var commits []*GitCommit
2124
2125 // Get commits since the initial commit
2126 // Format: <hash>\0<subject>\0<body>\0
2127 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2128 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002129 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 -07002130 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002131 output, err := cmd.Output()
2132 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002133 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002134 }
2135
2136 // Parse git log output and filter out already seen commits
2137 parsedCommits := parseGitLog(string(output))
2138
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002139 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002140
2141 // Filter out commits we've already seen
2142 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002143 if commit.Hash == sketch {
2144 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002145 }
2146
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002147 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2148 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002149 continue
2150 }
2151
2152 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002153 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002154
2155 // Add to our list of new commits
2156 commits = append(commits, &commit)
2157 }
2158
Philip Zeyligerf2872992025-05-22 10:35:28 -07002159 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002160 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002161 // 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 -07002162 sketchCommit = &GitCommit{}
2163 sketchCommit.Hash = sketch
2164 sketchCommit.Subject = "unknown"
2165 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002166 }
2167
Earl Lee2e463fb2025-04-17 11:22:22 -07002168 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2169 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2170 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002171
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002172 // 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 +00002173 var out []byte
2174 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002175 originalRetryNumber := ags.retryNumber
2176 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002177 for retries := range 10 {
2178 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002179 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002180 }
2181
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002182 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002183 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002184 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002185 out, err = cmd.CombinedOutput()
2186
2187 if err == nil {
2188 // Success! Break out of the retry loop
2189 break
2190 }
2191
2192 // Check if this is the "refusing to update checked out branch" error
2193 if !strings.Contains(string(out), "refusing to update checked out branch") {
2194 // This is a different error, so don't retry
2195 break
2196 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002197 }
2198
2199 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002200 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002201 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002202 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002203 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002204 if ags.retryNumber != originalRetryNumber {
2205 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002206 msgs = append(msgs, AgentMessage{
2207 Type: AutoMessageType,
2208 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002209 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 +00002210 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002211 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002212 }
2213 }
2214
2215 // If we found new commits, create a message
2216 if len(commits) > 0 {
2217 msg := AgentMessage{
2218 Type: CommitMessageType,
2219 Timestamp: time.Now(),
2220 Commits: commits,
2221 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002222 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002223 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002224 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002225}
2226
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002227func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002228 return strings.Map(func(r rune) rune {
2229 // lowercase
2230 if r >= 'A' && r <= 'Z' {
2231 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002232 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002233 // replace spaces with dashes
2234 if r == ' ' {
2235 return '-'
2236 }
2237 // allow alphanumerics and dashes
2238 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2239 return r
2240 }
2241 return -1
2242 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002243}
2244
2245// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2246// and returns an array of GitCommit structs.
2247func parseGitLog(output string) []GitCommit {
2248 var commits []GitCommit
2249
2250 // No output means no commits
2251 if len(output) == 0 {
2252 return commits
2253 }
2254
2255 // Split by NULL byte
2256 parts := strings.Split(output, "\x00")
2257
2258 // Process in triplets (hash, subject, body)
2259 for i := 0; i < len(parts); i++ {
2260 // Skip empty parts
2261 if parts[i] == "" {
2262 continue
2263 }
2264
2265 // This should be a hash
2266 hash := strings.TrimSpace(parts[i])
2267
2268 // Make sure we have at least a subject part available
2269 if i+1 >= len(parts) {
2270 break // No more parts available
2271 }
2272
2273 // Get the subject
2274 subject := strings.TrimSpace(parts[i+1])
2275
2276 // Get the body if available
2277 body := ""
2278 if i+2 < len(parts) {
2279 body = strings.TrimSpace(parts[i+2])
2280 }
2281
2282 // Skip to the next triplet
2283 i += 2
2284
2285 commits = append(commits, GitCommit{
2286 Hash: hash,
2287 Subject: subject,
2288 Body: body,
2289 })
2290 }
2291
2292 return commits
2293}
2294
2295func repoRoot(ctx context.Context, dir string) (string, error) {
2296 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2297 stderr := new(strings.Builder)
2298 cmd.Stderr = stderr
2299 cmd.Dir = dir
2300 out, err := cmd.Output()
2301 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002302 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002303 }
2304 return strings.TrimSpace(string(out)), nil
2305}
2306
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002307// upsertRemoteOrigin configures the origin remote to point to the given URL.
2308// If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
2309func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
2310 // Try to set the URL for existing origin remote
2311 cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
2312 cmd.Dir = repoDir
2313 if _, err := cmd.CombinedOutput(); err == nil {
2314 // Success.
2315 return nil
2316 }
2317 // Origin doesn't exist; add it.
2318 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL)
2319 cmd.Dir = repoDir
2320 if out, err := cmd.CombinedOutput(); err != nil {
2321 return fmt.Errorf("failed to add git remote origin: %s: %w", out, err)
2322 }
2323 return nil
2324}
2325
Earl Lee2e463fb2025-04-17 11:22:22 -07002326func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2327 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2328 stderr := new(strings.Builder)
2329 cmd.Stderr = stderr
2330 cmd.Dir = dir
2331 out, err := cmd.Output()
2332 if err != nil {
2333 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2334 }
2335 // TODO: validate that out is valid hex
2336 return strings.TrimSpace(string(out)), nil
2337}
2338
2339// isValidGitSHA validates if a string looks like a valid git SHA hash.
2340// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2341func isValidGitSHA(sha string) bool {
2342 // Git SHA must be a hexadecimal string with at least 4 characters
2343 if len(sha) < 4 || len(sha) > 40 {
2344 return false
2345 }
2346
2347 // Check if the string only contains hexadecimal characters
2348 for _, char := range sha {
2349 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2350 return false
2351 }
2352 }
2353
2354 return true
2355}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002356
Philip Zeyliger64f60462025-06-16 13:57:10 -07002357// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2358func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2359 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2360 cmd.Dir = repoRoot
2361 out, err := cmd.Output()
2362 if err != nil {
2363 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2364 }
2365
2366 var totalAdded, totalRemoved int
2367 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2368 for _, line := range lines {
2369 if line == "" {
2370 continue
2371 }
2372 parts := strings.Fields(line)
2373 if len(parts) < 2 {
2374 continue
2375 }
2376 // Format: <added>\t<removed>\t<filename>
2377 if added, err := strconv.Atoi(parts[0]); err == nil {
2378 totalAdded += added
2379 }
2380 if removed, err := strconv.Atoi(parts[1]); err == nil {
2381 totalRemoved += removed
2382 }
2383 }
2384
2385 return totalAdded, totalRemoved, nil
2386}
2387
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002388// systemPromptData contains the data used to render the system prompt template
2389type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002390 ClientGOOS string
2391 ClientGOARCH string
2392 WorkingDir string
2393 RepoRoot string
2394 InitialCommit string
2395 Codebase *onstart.Codebase
2396 UseSketchWIP bool
2397 Branch string
2398 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002399}
2400
2401// renderSystemPrompt renders the system prompt template.
2402func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002403 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002404 ClientGOOS: a.config.ClientGOOS,
2405 ClientGOARCH: a.config.ClientGOARCH,
2406 WorkingDir: a.workingDir,
2407 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002408 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002409 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002410 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002411 }
David Crawshawc886ac52025-06-13 23:40:03 +00002412 now := time.Now()
2413 if now.Month() == time.September && now.Day() == 19 {
2414 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2415 }
2416
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002417 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2418 if err != nil {
2419 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2420 }
2421 buf := new(strings.Builder)
2422 err = tmpl.Execute(buf, data)
2423 if err != nil {
2424 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2425 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002426 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002427 return buf.String()
2428}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002429
2430// StateTransitionIterator provides an iterator over state transitions.
2431type StateTransitionIterator interface {
2432 // Next blocks until a new state transition is available or context is done.
2433 // Returns nil if the context is cancelled.
2434 Next() *StateTransition
2435 // Close removes the listener and cleans up resources.
2436 Close()
2437}
2438
2439// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2440type StateTransitionIteratorImpl struct {
2441 agent *Agent
2442 ctx context.Context
2443 ch chan StateTransition
2444 unsubscribe func()
2445}
2446
2447// Next blocks until a new state transition is available or the context is cancelled.
2448func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2449 select {
2450 case <-s.ctx.Done():
2451 return nil
2452 case transition, ok := <-s.ch:
2453 if !ok {
2454 return nil
2455 }
2456 transitionCopy := transition
2457 return &transitionCopy
2458 }
2459}
2460
2461// Close removes the listener and cleans up resources.
2462func (s *StateTransitionIteratorImpl) Close() {
2463 if s.unsubscribe != nil {
2464 s.unsubscribe()
2465 s.unsubscribe = nil
2466 }
2467}
2468
2469// NewStateTransitionIterator returns an iterator that receives state transitions.
2470func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2471 a.mu.Lock()
2472 defer a.mu.Unlock()
2473
2474 // Create channel to receive state transitions
2475 ch := make(chan StateTransition, 10)
2476
2477 // Add a listener to the state machine
2478 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2479
2480 return &StateTransitionIteratorImpl{
2481 agent: a,
2482 ctx: ctx,
2483 ch: ch,
2484 unsubscribe: unsubscribe,
2485 }
2486}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002487
2488// setupGitHooks creates or updates git hooks in the specified working directory.
2489func setupGitHooks(workingDir string) error {
2490 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2491
2492 _, err := os.Stat(hooksDir)
2493 if os.IsNotExist(err) {
2494 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2495 }
2496 if err != nil {
2497 return fmt.Errorf("error checking git hooks directory: %w", err)
2498 }
2499
2500 // Define the post-commit hook content
2501 postCommitHook := `#!/bin/bash
2502echo "<post_commit_hook>"
2503echo "Please review this commit message and fix it if it is incorrect."
2504echo "This hook only echos the commit message; it does not modify it."
2505echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2506echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002507PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002508echo "</last_commit_message>"
2509echo "</post_commit_hook>"
2510`
2511
2512 // Define the prepare-commit-msg hook content
2513 prepareCommitMsgHook := `#!/bin/bash
2514# Add Co-Authored-By and Change-ID trailers to commit messages
2515# Check if these trailers already exist before adding them
2516
2517commit_file="$1"
2518COMMIT_SOURCE="$2"
2519
2520# Skip for merges, squashes, or when using a commit template
2521if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2522 [ "$COMMIT_SOURCE" = "squash" ]; then
2523 exit 0
2524fi
2525
2526commit_msg=$(cat "$commit_file")
2527
2528needs_co_author=true
2529needs_change_id=true
2530
2531# Check if commit message already has Co-Authored-By trailer
2532if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2533 needs_co_author=false
2534fi
2535
2536# Check if commit message already has Change-ID trailer
2537if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2538 needs_change_id=false
2539fi
2540
2541# Only modify if at least one trailer needs to be added
2542if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002543 # Ensure there's a proper blank line before trailers
2544 if [ -s "$commit_file" ]; then
2545 # Check if file ends with newline by reading last character
2546 last_char=$(tail -c 1 "$commit_file")
2547
2548 if [ "$last_char" != "" ]; then
2549 # File doesn't end with newline - add two newlines (complete line + blank line)
2550 echo "" >> "$commit_file"
2551 echo "" >> "$commit_file"
2552 else
2553 # File ends with newline - check if we already have a blank line
2554 last_line=$(tail -1 "$commit_file")
2555 if [ -n "$last_line" ]; then
2556 # Last line has content - add one newline for blank line
2557 echo "" >> "$commit_file"
2558 fi
2559 # If last line is empty, we already have a blank line - don't add anything
2560 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002561 fi
2562
2563 # Add trailers if needed
2564 if [ "$needs_co_author" = true ]; then
2565 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2566 fi
2567
2568 if [ "$needs_change_id" = true ]; then
2569 change_id=$(openssl rand -hex 8)
2570 echo "Change-ID: s${change_id}k" >> "$commit_file"
2571 fi
2572fi
2573`
2574
2575 // Update or create the post-commit hook
2576 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2577 if err != nil {
2578 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2579 }
2580
2581 // Update or create the prepare-commit-msg hook
2582 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2583 if err != nil {
2584 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2585 }
2586
2587 return nil
2588}
2589
2590// updateOrCreateHook creates a new hook file or updates an existing one
2591// by appending the new content if it doesn't already contain it.
2592func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2593 // Check if the hook already exists
2594 buf, err := os.ReadFile(hookPath)
2595 if os.IsNotExist(err) {
2596 // Hook doesn't exist, create it
2597 err = os.WriteFile(hookPath, []byte(content), 0o755)
2598 if err != nil {
2599 return fmt.Errorf("failed to create hook: %w", err)
2600 }
2601 return nil
2602 }
2603 if err != nil {
2604 return fmt.Errorf("error reading existing hook: %w", err)
2605 }
2606
2607 // Hook exists, check if our content is already in it by looking for a distinctive line
2608 code := string(buf)
2609 if strings.Contains(code, distinctiveLine) {
2610 // Already contains our content, nothing to do
2611 return nil
2612 }
2613
2614 // Append our content to the existing hook
2615 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2616 if err != nil {
2617 return fmt.Errorf("failed to open hook for appending: %w", err)
2618 }
2619 defer f.Close()
2620
2621 // Ensure there's a newline at the end of the existing content if needed
2622 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2623 _, err = f.WriteString("\n")
2624 if err != nil {
2625 return fmt.Errorf("failed to add newline to hook: %w", err)
2626 }
2627 }
2628
2629 // Add a separator before our content
2630 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2631 if err != nil {
2632 return fmt.Errorf("failed to append to hook: %w", err)
2633 }
2634
2635 return nil
2636}
Sean McCullough138ec242025-06-02 22:42:06 +00002637
Philip Zeyliger0113be52025-06-07 23:53:41 +00002638// SkabandAddr returns the skaband address if configured
2639func (a *Agent) SkabandAddr() string {
2640 if a.config.SkabandClient != nil {
2641 return a.config.SkabandClient.Addr()
2642 }
2643 return ""
2644}