blob: b698d22fb25389e1aff48641c0527402c9f71d4f [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07006 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/json"
8 "fmt"
9 "log/slog"
10 "net/http"
11 "os"
12 "os/exec"
13 "runtime/debug"
14 "slices"
15 "strings"
16 "sync"
17 "time"
18
19 "sketch.dev/ant"
20 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000021 "sketch.dev/claudetool/bashkit"
Earl Lee2e463fb2025-04-17 11:22:22 -070022)
23
24const (
25 userCancelMessage = "user requested agent to stop handling responses"
26)
27
28type CodingAgent interface {
29 // Init initializes an agent inside a docker container.
30 Init(AgentInit) error
31
32 // Ready returns a channel closed after Init successfully called.
33 Ready() <-chan struct{}
34
35 // URL reports the HTTP URL of this agent.
36 URL() string
37
38 // UserMessage enqueues a message to the agent and returns immediately.
39 UserMessage(ctx context.Context, msg string)
40
41 // WaitForMessage blocks until the agent has a response to give.
42 // Use AgentMessage.EndOfTurn to help determine if you want to
43 // drain the agent.
44 WaitForMessage(ctx context.Context) AgentMessage
45
46 // Loop begins the agent loop returns only when ctx is cancelled.
47 Loop(ctx context.Context)
48
Sean McCulloughedc88dc2025-04-30 02:55:01 +000049 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070050
51 CancelToolUse(toolUseID string, cause error) error
52
53 // Returns a subset of the agent's message history.
54 Messages(start int, end int) []AgentMessage
55
56 // Returns the current number of messages in the history
57 MessageCount() int
58
59 TotalUsage() ant.CumulativeUsage
60 OriginalBudget() ant.Budget
61
62 // WaitForMessageCount returns when the agent has at more than clientMessageCount messages or the context is done.
63 WaitForMessageCount(ctx context.Context, greaterThan int)
64
65 WorkingDir() string
66
67 // Diff returns a unified diff of changes made since the agent was instantiated.
68 // If commit is non-nil, it shows the diff for just that specific commit.
69 Diff(commit *string) (string, error)
70
71 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
72 InitialCommit() string
73
74 // Title returns the current title of the conversation.
75 Title() string
76
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000077 // BranchName returns the git branch name for the conversation.
78 BranchName() string
79
Earl Lee2e463fb2025-04-17 11:22:22 -070080 // OS returns the operating system of the client.
81 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000082
Philip Zeyligerc72fff52025-04-29 20:17:54 +000083 // SessionID returns the unique session identifier.
84 SessionID() string
85
Philip Zeyliger99a9a022025-04-27 15:15:25 +000086 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
87 OutstandingLLMCallCount() int
88
89 // OutstandingToolCalls returns the names of outstanding tool calls.
90 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +000091 OutsideOS() string
92 OutsideHostname() string
93 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +000094 GitOrigin() string
Philip Zeyliger2c4db092025-04-28 16:57:50 -070095
96 // RestartConversation resets the conversation history
97 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
98 // SuggestReprompt suggests a re-prompt based on the current conversation.
99 SuggestReprompt(ctx context.Context) (string, error)
100 // IsInContainer returns true if the agent is running in a container
101 IsInContainer() bool
102 // FirstMessageIndex returns the index of the first message in the current conversation
103 FirstMessageIndex() int
Earl Lee2e463fb2025-04-17 11:22:22 -0700104}
105
106type CodingAgentMessageType string
107
108const (
109 UserMessageType CodingAgentMessageType = "user"
110 AgentMessageType CodingAgentMessageType = "agent"
111 ErrorMessageType CodingAgentMessageType = "error"
112 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
113 ToolUseMessageType CodingAgentMessageType = "tool"
114 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
115 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
116
117 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
118)
119
120type AgentMessage struct {
121 Type CodingAgentMessageType `json:"type"`
122 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
123 EndOfTurn bool `json:"end_of_turn"`
124
125 Content string `json:"content"`
126 ToolName string `json:"tool_name,omitempty"`
127 ToolInput string `json:"input,omitempty"`
128 ToolResult string `json:"tool_result,omitempty"`
129 ToolError bool `json:"tool_error,omitempty"`
130 ToolCallId string `json:"tool_call_id,omitempty"`
131
132 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
133 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
134
Sean McCulloughd9f13372025-04-21 15:08:49 -0700135 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
136 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
137
Earl Lee2e463fb2025-04-17 11:22:22 -0700138 // Commits is a list of git commits for a commit message
139 Commits []*GitCommit `json:"commits,omitempty"`
140
141 Timestamp time.Time `json:"timestamp"`
142 ConversationID string `json:"conversation_id"`
143 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
144 Usage *ant.Usage `json:"usage,omitempty"`
145
146 // Message timing information
147 StartTime *time.Time `json:"start_time,omitempty"`
148 EndTime *time.Time `json:"end_time,omitempty"`
149 Elapsed *time.Duration `json:"elapsed,omitempty"`
150
151 // Turn duration - the time taken for a complete agent turn
152 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
153
154 Idx int `json:"idx"`
155}
156
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700157// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
158func (m *AgentMessage) SetConvo(convo *ant.Convo) {
159 if convo == nil {
160 m.ConversationID = ""
161 m.ParentConversationID = nil
162 return
163 }
164 m.ConversationID = convo.ID
165 if convo.Parent != nil {
166 m.ParentConversationID = &convo.Parent.ID
167 }
168}
169
Earl Lee2e463fb2025-04-17 11:22:22 -0700170// GitCommit represents a single git commit for a commit message
171type GitCommit struct {
172 Hash string `json:"hash"` // Full commit hash
173 Subject string `json:"subject"` // Commit subject line
174 Body string `json:"body"` // Full commit message body
175 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
176}
177
178// ToolCall represents a single tool call within an agent message
179type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700180 Name string `json:"name"`
181 Input string `json:"input"`
182 ToolCallId string `json:"tool_call_id"`
183 ResultMessage *AgentMessage `json:"result_message,omitempty"`
184 Args string `json:"args,omitempty"`
185 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700186}
187
188func (a *AgentMessage) Attr() slog.Attr {
189 var attrs []any = []any{
190 slog.String("type", string(a.Type)),
191 }
192 if a.EndOfTurn {
193 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
194 }
195 if a.Content != "" {
196 attrs = append(attrs, slog.String("content", a.Content))
197 }
198 if a.ToolName != "" {
199 attrs = append(attrs, slog.String("tool_name", a.ToolName))
200 }
201 if a.ToolInput != "" {
202 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
203 }
204 if a.Elapsed != nil {
205 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
206 }
207 if a.TurnDuration != nil {
208 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
209 }
210 if a.ToolResult != "" {
211 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
212 }
213 if a.ToolError {
214 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
215 }
216 if len(a.ToolCalls) > 0 {
217 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
218 for i, tc := range a.ToolCalls {
219 toolCallAttrs = append(toolCallAttrs, slog.Group(
220 fmt.Sprintf("tool_call_%d", i),
221 slog.String("name", tc.Name),
222 slog.String("input", tc.Input),
223 ))
224 }
225 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
226 }
227 if a.ConversationID != "" {
228 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
229 }
230 if a.ParentConversationID != nil {
231 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
232 }
233 if a.Usage != nil && !a.Usage.IsZero() {
234 attrs = append(attrs, a.Usage.Attr())
235 }
236 // TODO: timestamp, convo ids, idx?
237 return slog.Group("agent_message", attrs...)
238}
239
240func errorMessage(err error) AgentMessage {
241 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
242 if os.Getenv(("DEBUG")) == "1" {
243 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
244 }
245
246 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
247}
248
249func budgetMessage(err error) AgentMessage {
250 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
251}
252
253// ConvoInterface defines the interface for conversation interactions
254type ConvoInterface interface {
255 CumulativeUsage() ant.CumulativeUsage
256 ResetBudget(ant.Budget)
257 OverBudget() error
258 SendMessage(message ant.Message) (*ant.MessageResponse, error)
259 SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700260 GetID() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700261 ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
262 ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error)
263 CancelToolUse(toolUseID string, cause error) error
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700264 SubConvoWithHistory() *ant.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700265}
266
267type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700268 convo ConvoInterface
269 config AgentConfig // config for this agent
270 workingDir string
271 repoRoot string // workingDir may be a subdir of repoRoot
272 url string
273 firstMessageIndex int // index of the first message in the current conversation
274 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
275 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
276 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
277 ready chan struct{} // closed when the agent is initialized (only when under docker)
278 startedAt time.Time
279 originalBudget ant.Budget
280 title string
281 branchName string
282 codereview *claudetool.CodeReviewer
Philip Zeyliger18532b22025-04-23 21:11:46 +0000283 // Outside information
284 outsideHostname string
285 outsideOS string
286 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000287 // URL of the git remote 'origin' if it exists
288 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700289
290 // Time when the current turn started (reset at the beginning of InnerLoop)
291 startOfTurn time.Time
292
293 // Inbox - for messages from the user to the agent.
294 // sent on by UserMessage
295 // . e.g. when user types into the chat textarea
296 // read from by GatherMessages
297 inbox chan string
298
299 // Outbox
300 // sent on by pushToOutbox
301 // via OnToolResult and OnResponse callbacks
302 // read from by WaitForMessage
303 // called by termui inside its repl loop.
304 outbox chan AgentMessage
305
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000306 // protects cancelTurn
307 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700308 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000309 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700310
311 // protects following
312 mu sync.Mutex
313
314 // Stores all messages for this agent
315 history []AgentMessage
316
317 listeners []chan struct{}
318
319 // Track git commits we've already seen (by hash)
320 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000321
322 // Track outstanding LLM call IDs
323 outstandingLLMCalls map[string]struct{}
324
325 // Track outstanding tool calls by ID with their names
326 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700327}
328
329func (a *Agent) URL() string { return a.url }
330
331// Title returns the current title of the conversation.
332// If no title has been set, returns an empty string.
333func (a *Agent) Title() string {
334 a.mu.Lock()
335 defer a.mu.Unlock()
336 return a.title
337}
338
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000339// BranchName returns the git branch name for the conversation.
340func (a *Agent) BranchName() string {
341 a.mu.Lock()
342 defer a.mu.Unlock()
343 return a.branchName
344}
345
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000346// OutstandingLLMCallCount returns the number of outstanding LLM calls.
347func (a *Agent) OutstandingLLMCallCount() int {
348 a.mu.Lock()
349 defer a.mu.Unlock()
350 return len(a.outstandingLLMCalls)
351}
352
353// OutstandingToolCalls returns the names of outstanding tool calls.
354func (a *Agent) OutstandingToolCalls() []string {
355 a.mu.Lock()
356 defer a.mu.Unlock()
357
358 tools := make([]string, 0, len(a.outstandingToolCalls))
359 for _, toolName := range a.outstandingToolCalls {
360 tools = append(tools, toolName)
361 }
362 return tools
363}
364
Earl Lee2e463fb2025-04-17 11:22:22 -0700365// OS returns the operating system of the client.
366func (a *Agent) OS() string {
367 return a.config.ClientGOOS
368}
369
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000370func (a *Agent) SessionID() string {
371 return a.config.SessionID
372}
373
Philip Zeyliger18532b22025-04-23 21:11:46 +0000374// OutsideOS returns the operating system of the outside system.
375func (a *Agent) OutsideOS() string {
376 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000377}
378
Philip Zeyliger18532b22025-04-23 21:11:46 +0000379// OutsideHostname returns the hostname of the outside system.
380func (a *Agent) OutsideHostname() string {
381 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000382}
383
Philip Zeyliger18532b22025-04-23 21:11:46 +0000384// OutsideWorkingDir returns the working directory on the outside system.
385func (a *Agent) OutsideWorkingDir() string {
386 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000387}
388
389// GitOrigin returns the URL of the git remote 'origin' if it exists.
390func (a *Agent) GitOrigin() string {
391 return a.gitOrigin
392}
393
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700394func (a *Agent) IsInContainer() bool {
395 return a.config.InDocker
396}
397
398func (a *Agent) FirstMessageIndex() int {
399 a.mu.Lock()
400 defer a.mu.Unlock()
401 return a.firstMessageIndex
402}
403
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700404// SetTitleBranch sets the title and branch name of the conversation.
405func (a *Agent) SetTitleBranch(title, branchName string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700406 a.mu.Lock()
407 defer a.mu.Unlock()
408 a.title = title
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700409 a.branchName = branchName
Earl Lee2e463fb2025-04-17 11:22:22 -0700410 // Notify all listeners that the state has changed
411 for _, ch := range a.listeners {
412 close(ch)
413 }
414 a.listeners = a.listeners[:0]
415}
416
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000417// OnToolCall implements ant.Listener and tracks the start of a tool call.
418func (a *Agent) OnToolCall(ctx context.Context, convo *ant.Convo, id string, toolName string, toolInput json.RawMessage, content ant.Content) {
419 // Track the tool call
420 a.mu.Lock()
421 a.outstandingToolCalls[id] = toolName
422 a.mu.Unlock()
423}
424
Earl Lee2e463fb2025-04-17 11:22:22 -0700425// OnToolResult implements ant.Listener.
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000426func (a *Agent) OnToolResult(ctx context.Context, convo *ant.Convo, toolID string, toolName string, toolInput json.RawMessage, content ant.Content, result *string, err error) {
427 // Remove the tool call from outstanding calls
428 a.mu.Lock()
429 delete(a.outstandingToolCalls, toolID)
430 a.mu.Unlock()
431
Earl Lee2e463fb2025-04-17 11:22:22 -0700432 m := AgentMessage{
433 Type: ToolUseMessageType,
434 Content: content.Text,
435 ToolResult: content.ToolResult,
436 ToolError: content.ToolError,
437 ToolName: toolName,
438 ToolInput: string(toolInput),
439 ToolCallId: content.ToolUseID,
440 StartTime: content.StartTime,
441 EndTime: content.EndTime,
442 }
443
444 // Calculate the elapsed time if both start and end times are set
445 if content.StartTime != nil && content.EndTime != nil {
446 elapsed := content.EndTime.Sub(*content.StartTime)
447 m.Elapsed = &elapsed
448 }
449
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700450 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700451 a.pushToOutbox(ctx, m)
452}
453
454// OnRequest implements ant.Listener.
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000455func (a *Agent) OnRequest(ctx context.Context, convo *ant.Convo, id string, msg *ant.Message) {
456 a.mu.Lock()
457 defer a.mu.Unlock()
458 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700459 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
460}
461
462// OnResponse implements ant.Listener. Responses contain messages from the LLM
463// that need to be displayed (as well as tool calls that we send along when
464// they're done). (It would be reasonable to also mention tool calls when they're
465// started, but we don't do that yet.)
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000466func (a *Agent) OnResponse(ctx context.Context, convo *ant.Convo, id string, resp *ant.MessageResponse) {
467 // Remove the LLM call from outstanding calls
468 a.mu.Lock()
469 delete(a.outstandingLLMCalls, id)
470 a.mu.Unlock()
471
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700472 if resp == nil {
473 // LLM API call failed
474 m := AgentMessage{
475 Type: ErrorMessageType,
476 Content: "API call failed, type 'continue' to try again",
477 }
478 m.SetConvo(convo)
479 a.pushToOutbox(ctx, m)
480 return
481 }
482
Earl Lee2e463fb2025-04-17 11:22:22 -0700483 endOfTurn := false
484 if resp.StopReason != ant.StopReasonToolUse {
485 endOfTurn = true
486 }
487 m := AgentMessage{
488 Type: AgentMessageType,
489 Content: collectTextContent(resp),
490 EndOfTurn: endOfTurn,
491 Usage: &resp.Usage,
492 StartTime: resp.StartTime,
493 EndTime: resp.EndTime,
494 }
495
496 // Extract any tool calls from the response
497 if resp.StopReason == ant.StopReasonToolUse {
498 var toolCalls []ToolCall
499 for _, part := range resp.Content {
500 if part.Type == "tool_use" {
501 toolCalls = append(toolCalls, ToolCall{
502 Name: part.ToolName,
503 Input: string(part.ToolInput),
504 ToolCallId: part.ID,
505 })
506 }
507 }
508 m.ToolCalls = toolCalls
509 }
510
511 // Calculate the elapsed time if both start and end times are set
512 if resp.StartTime != nil && resp.EndTime != nil {
513 elapsed := resp.EndTime.Sub(*resp.StartTime)
514 m.Elapsed = &elapsed
515 }
516
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700517 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700518 a.pushToOutbox(ctx, m)
519}
520
521// WorkingDir implements CodingAgent.
522func (a *Agent) WorkingDir() string {
523 return a.workingDir
524}
525
526// MessageCount implements CodingAgent.
527func (a *Agent) MessageCount() int {
528 a.mu.Lock()
529 defer a.mu.Unlock()
530 return len(a.history)
531}
532
533// Messages implements CodingAgent.
534func (a *Agent) Messages(start int, end int) []AgentMessage {
535 a.mu.Lock()
536 defer a.mu.Unlock()
537 return slices.Clone(a.history[start:end])
538}
539
540func (a *Agent) OriginalBudget() ant.Budget {
541 return a.originalBudget
542}
543
544// AgentConfig contains configuration for creating a new Agent.
545type AgentConfig struct {
546 Context context.Context
547 AntURL string
548 APIKey string
549 HTTPC *http.Client
550 Budget ant.Budget
551 GitUsername string
552 GitEmail string
553 SessionID string
554 ClientGOOS string
555 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700556 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700557 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000558 // Outside information
559 OutsideHostname string
560 OutsideOS string
561 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700562}
563
564// NewAgent creates a new Agent.
565// It is not usable until Init() is called.
566func NewAgent(config AgentConfig) *Agent {
567 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000568 config: config,
569 ready: make(chan struct{}),
570 inbox: make(chan string, 100),
571 outbox: make(chan AgentMessage, 100),
572 startedAt: time.Now(),
573 originalBudget: config.Budget,
574 seenCommits: make(map[string]bool),
575 outsideHostname: config.OutsideHostname,
576 outsideOS: config.OutsideOS,
577 outsideWorkingDir: config.OutsideWorkingDir,
578 outstandingLLMCalls: make(map[string]struct{}),
579 outstandingToolCalls: make(map[string]string),
Earl Lee2e463fb2025-04-17 11:22:22 -0700580 }
581 return agent
582}
583
584type AgentInit struct {
585 WorkingDir string
586 NoGit bool // only for testing
587
588 InDocker bool
589 Commit string
590 GitRemoteAddr string
591 HostAddr string
592}
593
594func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700595 if a.convo != nil {
596 return fmt.Errorf("Agent.Init: already initialized")
597 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700598 ctx := a.config.Context
599 if ini.InDocker {
600 cmd := exec.CommandContext(ctx, "git", "stash")
601 cmd.Dir = ini.WorkingDir
602 if out, err := cmd.CombinedOutput(); err != nil {
603 return fmt.Errorf("git stash: %s: %v", out, err)
604 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700605 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
606 cmd.Dir = ini.WorkingDir
607 if out, err := cmd.CombinedOutput(); err != nil {
608 return fmt.Errorf("git remote add: %s: %v", out, err)
609 }
610 cmd = exec.CommandContext(ctx, "git", "fetch", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700611 cmd.Dir = ini.WorkingDir
612 if out, err := cmd.CombinedOutput(); err != nil {
613 return fmt.Errorf("git fetch: %s: %w", out, err)
614 }
615 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
616 cmd.Dir = ini.WorkingDir
617 if out, err := cmd.CombinedOutput(); err != nil {
618 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
619 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700620 a.lastHEAD = ini.Commit
621 a.gitRemoteAddr = ini.GitRemoteAddr
622 a.initialCommit = ini.Commit
623 if ini.HostAddr != "" {
624 a.url = "http://" + ini.HostAddr
625 }
626 }
627 a.workingDir = ini.WorkingDir
628
629 if !ini.NoGit {
630 repoRoot, err := repoRoot(ctx, a.workingDir)
631 if err != nil {
632 return fmt.Errorf("repoRoot: %w", err)
633 }
634 a.repoRoot = repoRoot
635
636 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
637 if err != nil {
638 return fmt.Errorf("resolveRef: %w", err)
639 }
640 a.initialCommit = commitHash
641
642 codereview, err := claudetool.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit)
643 if err != nil {
644 return fmt.Errorf("Agent.Init: claudetool.NewCodeReviewer: %w", err)
645 }
646 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000647
648 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700649 }
650 a.lastHEAD = a.initialCommit
651 a.convo = a.initConvo()
652 close(a.ready)
653 return nil
654}
655
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700656//go:embed agent_system_prompt.txt
657var agentSystemPrompt string
658
Earl Lee2e463fb2025-04-17 11:22:22 -0700659// initConvo initializes the conversation.
660// It must not be called until all agent fields are initialized,
661// particularly workingDir and git.
662func (a *Agent) initConvo() *ant.Convo {
663 ctx := a.config.Context
664 convo := ant.NewConvo(ctx, a.config.APIKey)
665 if a.config.HTTPC != nil {
666 convo.HTTPC = a.config.HTTPC
667 }
668 if a.config.AntURL != "" {
669 convo.URL = a.config.AntURL
670 }
671 convo.PromptCaching = true
672 convo.Budget = a.config.Budget
673
674 var editPrompt string
675 if a.config.UseAnthropicEdit {
676 editPrompt = "Then use the str_replace_editor tool to make those edits. For short complete file replacements, you may use the bash tool with cat and heredoc stdin."
677 } else {
678 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
679 }
680
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700681 convo.SystemPrompt = fmt.Sprintf(agentSystemPrompt, editPrompt, a.config.ClientGOOS, a.config.ClientGOARCH, a.workingDir, a.repoRoot, a.initialCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -0700682
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000683 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
684 bashPermissionCheck := func(command string) error {
685 // Check if branch name is set
686 a.mu.Lock()
687 branchSet := a.branchName != ""
688 a.mu.Unlock()
689
690 // If branch is set, all commands are allowed
691 if branchSet {
692 return nil
693 }
694
695 // If branch is not set, check if this is a git commit command
696 willCommit, err := bashkit.WillRunGitCommit(command)
697 if err != nil {
698 // If there's an error checking, we should allow the command to proceed
699 return nil
700 }
701
702 // If it's a git commit and branch is not set, return an error
703 if willCommit {
704 return fmt.Errorf("you must use the title tool before making git commits")
705 }
706
707 return nil
708 }
709
710 // Create a custom bash tool with the permission check
711 bashTool := claudetool.NewBashTool(bashPermissionCheck)
712
Earl Lee2e463fb2025-04-17 11:22:22 -0700713 // Register all tools with the conversation
714 // When adding, removing, or modifying tools here, double-check that the termui tool display
715 // template in termui/termui.go has pretty-printing support for all tools.
716 convo.Tools = []*ant.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000717 bashTool, claudetool.Keyword,
Earl Lee2e463fb2025-04-17 11:22:22 -0700718 claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
719 a.codereview.Tool(),
720 }
721 if a.config.UseAnthropicEdit {
722 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
723 } else {
724 convo.Tools = append(convo.Tools, claudetool.Patch)
725 }
726 convo.Listener = a
727 return convo
728}
729
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000730// branchExists reports whether branchName exists, either locally or in well-known remotes.
731func branchExists(dir, branchName string) bool {
732 refs := []string{
733 "refs/heads/",
734 "refs/remotes/origin/",
735 "refs/remotes/sketch-host/",
736 }
737 for _, ref := range refs {
738 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
739 cmd.Dir = dir
740 if cmd.Run() == nil { // exit code 0 means branch exists
741 return true
742 }
743 }
744 return false
745}
746
Earl Lee2e463fb2025-04-17 11:22:22 -0700747func (a *Agent) titleTool() *ant.Tool {
Earl Lee2e463fb2025-04-17 11:22:22 -0700748 title := &ant.Tool{
749 Name: "title",
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700750 Description: `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`,
Earl Lee2e463fb2025-04-17 11:22:22 -0700751 InputSchema: json.RawMessage(`{
752 "type": "object",
753 "properties": {
754 "title": {
755 "type": "string",
Josh Bleecher Snyder250348e2025-04-30 10:31:28 -0700756 "description": "A concise title summarizing what this conversation is about, imperative tense preferred"
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700757 },
758 "branch_name": {
759 "type": "string",
760 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700761 }
762 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700763 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700764}`),
765 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
766 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700767 Title string `json:"title"`
768 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700769 }
770 if err := json.Unmarshal(input, &params); err != nil {
771 return "", err
772 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700773 // It's unfortunate to not allow title changes,
774 // but it avoids having multiple branches.
775 t := a.Title()
776 if t != "" {
777 return "", fmt.Errorf("title already set to: %s", t)
778 }
779
780 if params.BranchName == "" {
781 return "", fmt.Errorf("branch_name parameter cannot be empty")
782 }
783 if params.Title == "" {
784 return "", fmt.Errorf("title parameter cannot be empty")
785 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -0700786 if params.BranchName != cleanBranchName(params.BranchName) {
787 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
788 }
789 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000790 if branchExists(a.workingDir, branchName) {
791 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
792 }
793
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700794 a.SetTitleBranch(params.Title, branchName)
795
796 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
797 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700798 },
799 }
800 return title
801}
802
803func (a *Agent) Ready() <-chan struct{} {
804 return a.ready
805}
806
807func (a *Agent) UserMessage(ctx context.Context, msg string) {
808 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
809 a.inbox <- msg
810}
811
812func (a *Agent) WaitForMessage(ctx context.Context) AgentMessage {
813 // TODO: Should this drain any outbox messages in case there are multiple?
814 select {
815 case msg := <-a.outbox:
816 return msg
817 case <-ctx.Done():
818 return errorMessage(ctx.Err())
819 }
820}
821
822func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
823 return a.convo.CancelToolUse(toolUseID, cause)
824}
825
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000826func (a *Agent) CancelTurn(cause error) {
827 a.cancelTurnMu.Lock()
828 defer a.cancelTurnMu.Unlock()
829 if a.cancelTurn != nil {
830 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -0700831 }
832}
833
834func (a *Agent) Loop(ctxOuter context.Context) {
835 for {
836 select {
837 case <-ctxOuter.Done():
838 return
839 default:
840 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000841 a.cancelTurnMu.Lock()
842 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +0000843 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000844 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -0700845 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000846 a.cancelTurn = cancel
847 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +0000848 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
849 if err != nil {
850 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
851 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700852 cancel(nil)
853 }
854 }
855}
856
857func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
858 if m.Timestamp.IsZero() {
859 m.Timestamp = time.Now()
860 }
861
862 // If this is an end-of-turn message, calculate the turn duration and add it to the message
863 if m.EndOfTurn && m.Type == AgentMessageType {
864 turnDuration := time.Since(a.startOfTurn)
865 m.TurnDuration = &turnDuration
866 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
867 }
868
869 slog.InfoContext(ctx, "agent message", m.Attr())
870
871 a.mu.Lock()
872 defer a.mu.Unlock()
873 m.Idx = len(a.history)
874 a.history = append(a.history, m)
875 a.outbox <- m
876
877 // Notify all listeners:
878 for _, ch := range a.listeners {
879 close(ch)
880 }
881 a.listeners = a.listeners[:0]
882}
883
884func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]ant.Content, error) {
885 var m []ant.Content
886 if block {
887 select {
888 case <-ctx.Done():
889 return m, ctx.Err()
890 case msg := <-a.inbox:
891 m = append(m, ant.Content{Type: "text", Text: msg})
892 }
893 }
894 for {
895 select {
896 case msg := <-a.inbox:
897 m = append(m, ant.Content{Type: "text", Text: msg})
898 default:
899 return m, nil
900 }
901 }
902}
903
Sean McCullough885a16a2025-04-30 02:49:25 +0000904// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +0000905func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700906 // Reset the start of turn time
907 a.startOfTurn = time.Now()
908
Sean McCullough885a16a2025-04-30 02:49:25 +0000909 // Process initial user message
910 initialResp, err := a.processUserMessage(ctx)
911 if err != nil {
Sean McCullough9f4b8082025-04-30 17:34:07 +0000912 return err
913 }
914
915 // Handle edge case where both initialResp and err are nil
916 if initialResp == nil {
917 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
918 a.pushToOutbox(ctx, errorMessage(err))
919 return err
Earl Lee2e463fb2025-04-17 11:22:22 -0700920 }
Sean McCullough885a16a2025-04-30 02:49:25 +0000921
Earl Lee2e463fb2025-04-17 11:22:22 -0700922 // We do this as we go, but let's also do it at the end of the turn
923 defer func() {
924 if _, err := a.handleGitCommits(ctx); err != nil {
925 // Just log the error, don't stop execution
926 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
927 }
928 }()
929
Sean McCullough885a16a2025-04-30 02:49:25 +0000930 // Main response loop - continue as long as the model is using tools
931 resp := initialResp
932 for {
933 // Check if we are over budget
934 if err := a.overBudget(ctx); err != nil {
Sean McCullough9f4b8082025-04-30 17:34:07 +0000935 return err
Sean McCullough885a16a2025-04-30 02:49:25 +0000936 }
937
938 // If the model is not requesting to use a tool, we're done
939 if resp.StopReason != ant.StopReasonToolUse {
940 break
941 }
942
943 // Handle tool execution
944 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
945 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +0000946 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +0000947 }
948
949 // Set the response for the next iteration
950 resp = toolResp
951 }
Sean McCullough9f4b8082025-04-30 17:34:07 +0000952
953 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +0000954}
955
956// processUserMessage waits for user messages and sends them to the model
957func (a *Agent) processUserMessage(ctx context.Context) (*ant.MessageResponse, error) {
958 // Wait for at least one message from the user
959 msgs, err := a.GatherMessages(ctx, true)
960 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
961 return nil, err
962 }
963
Earl Lee2e463fb2025-04-17 11:22:22 -0700964 userMessage := ant.Message{
965 Role: "user",
966 Content: msgs,
967 }
Sean McCullough885a16a2025-04-30 02:49:25 +0000968
969 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -0700970 resp, err := a.convo.SendMessage(userMessage)
971 if err != nil {
972 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +0000973 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -0700974 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700975
Sean McCullough885a16a2025-04-30 02:49:25 +0000976 return resp, nil
977}
978
979// handleToolExecution processes a tool use request from the model
980func (a *Agent) handleToolExecution(ctx context.Context, resp *ant.MessageResponse) (bool, *ant.MessageResponse) {
981 var results []ant.Content
982 cancelled := false
983
984 // Check if the operation was cancelled by the user
985 select {
986 case <-ctx.Done():
987 // Don't actually run any of the tools, but rather build a response
988 // for each tool_use message letting the LLM know that user canceled it.
989 var err error
990 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -0700991 if err != nil {
Sean McCullough885a16a2025-04-30 02:49:25 +0000992 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -0700993 }
Sean McCullough885a16a2025-04-30 02:49:25 +0000994 cancelled = true
995 default:
996 // Add working directory to context for tool execution
997 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
998
999 // Execute the tools
1000 var err error
1001 results, err = a.convo.ToolResultContents(ctx, resp)
1002 if ctx.Err() != nil { // e.g. the user canceled the operation
1003 cancelled = true
1004 } else if err != nil {
1005 a.pushToOutbox(ctx, errorMessage(err))
1006 }
1007 }
1008
1009 // Process git commits that may have occurred during tool execution
1010 autoqualityMessages := a.processGitChanges(ctx)
1011
1012 // Check budget again after tool execution
1013 if err := a.overBudget(ctx); err != nil {
1014 return false, nil
1015 }
1016
1017 // Continue the conversation with tool results and any user messages
1018 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1019}
1020
1021// processGitChanges checks for new git commits and runs autoformatters if needed
1022func (a *Agent) processGitChanges(ctx context.Context) []string {
1023 // Check for git commits after tool execution
1024 newCommits, err := a.handleGitCommits(ctx)
1025 if err != nil {
1026 // Just log the error, don't stop execution
1027 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1028 return nil
1029 }
1030
1031 // Run autoformatters if there was exactly one new commit
1032 var autoqualityMessages []string
1033 if len(newCommits) == 1 {
1034 formatted := a.codereview.Autoformat(ctx)
1035 if len(formatted) > 0 {
1036 msg := fmt.Sprintf(`
Earl Lee2e463fb2025-04-17 11:22:22 -07001037I ran autoformatters and they updated these files:
1038
1039%s
1040
1041Please amend your latest git commit with these changes and then continue with what you were doing.`,
Sean McCullough885a16a2025-04-30 02:49:25 +00001042 strings.Join(formatted, "\n"),
1043 )[1:]
1044 a.pushToOutbox(ctx, AgentMessage{
1045 Type: AutoMessageType,
1046 Content: msg,
1047 Timestamp: time.Now(),
1048 })
1049 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001050 }
1051 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001052
1053 return autoqualityMessages
1054}
1055
1056// continueTurnWithToolResults continues the conversation with tool results
1057func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []ant.Content, autoqualityMessages []string, cancelled bool) (bool, *ant.MessageResponse) {
1058 // Get any messages the user sent while tools were executing
1059 msgs, err := a.GatherMessages(ctx, false)
1060 if err != nil {
1061 return false, nil
1062 }
1063
1064 // Inject any auto-generated messages from quality checks
1065 for _, msg := range autoqualityMessages {
1066 msgs = append(msgs, ant.Content{Type: "text", Text: msg})
1067 }
1068
1069 // Handle cancellation by appending a message about it
1070 if cancelled {
1071 msgs = append(msgs, ant.Content{Type: "text", Text: cancelToolUseMessage})
1072 // EndOfTurn is false here so that the client of this agent keeps processing
1073 // messages from WaitForMessage() and gets the response from the LLM
1074 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1075 } else if err := a.convo.OverBudget(); err != nil {
1076 // Handle budget issues by appending a message about it
1077 budgetMsg := "We've exceeded our budget. Please ask the user to confirm before continuing by ending the turn."
1078 msgs = append(msgs, ant.Content{Type: "text", Text: budgetMsg})
1079 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1080 }
1081
1082 // Combine tool results with user messages
1083 results = append(results, msgs...)
1084
1085 // Send the combined message to continue the conversation
1086 resp, err := a.convo.SendMessage(ant.Message{
1087 Role: "user",
1088 Content: results,
1089 })
1090 if err != nil {
1091 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1092 return true, nil // Return true to continue the conversation, but with no response
1093 }
1094
1095 if cancelled {
1096 return false, nil
1097 }
1098
1099 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001100}
1101
1102func (a *Agent) overBudget(ctx context.Context) error {
1103 if err := a.convo.OverBudget(); err != nil {
1104 m := budgetMessage(err)
1105 m.Content = m.Content + "\n\nBudget reset."
1106 a.pushToOutbox(ctx, budgetMessage(err))
1107 a.convo.ResetBudget(a.originalBudget)
1108 return err
1109 }
1110 return nil
1111}
1112
1113func collectTextContent(msg *ant.MessageResponse) string {
1114 // Collect all text content
1115 var allText strings.Builder
1116 for _, content := range msg.Content {
1117 if content.Type == "text" && content.Text != "" {
1118 if allText.Len() > 0 {
1119 allText.WriteString("\n\n")
1120 }
1121 allText.WriteString(content.Text)
1122 }
1123 }
1124 return allText.String()
1125}
1126
1127func (a *Agent) TotalUsage() ant.CumulativeUsage {
1128 a.mu.Lock()
1129 defer a.mu.Unlock()
1130 return a.convo.CumulativeUsage()
1131}
1132
1133// WaitForMessageCount returns when the agent has at more than clientMessageCount messages or the context is done.
1134func (a *Agent) WaitForMessageCount(ctx context.Context, greaterThan int) {
1135 for a.MessageCount() <= greaterThan {
1136 a.mu.Lock()
1137 ch := make(chan struct{})
1138 // Deletion happens when we notify.
1139 a.listeners = append(a.listeners, ch)
1140 a.mu.Unlock()
1141
1142 select {
1143 case <-ctx.Done():
1144 return
1145 case <-ch:
1146 continue
1147 }
1148 }
1149}
1150
1151// Diff returns a unified diff of changes made since the agent was instantiated.
1152func (a *Agent) Diff(commit *string) (string, error) {
1153 if a.initialCommit == "" {
1154 return "", fmt.Errorf("no initial commit reference available")
1155 }
1156
1157 // Find the repository root
1158 ctx := context.Background()
1159
1160 // If a specific commit hash is provided, show just that commit's changes
1161 if commit != nil && *commit != "" {
1162 // Validate that the commit looks like a valid git SHA
1163 if !isValidGitSHA(*commit) {
1164 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1165 }
1166
1167 // Get the diff for just this commit
1168 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1169 cmd.Dir = a.repoRoot
1170 output, err := cmd.CombinedOutput()
1171 if err != nil {
1172 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1173 }
1174 return string(output), nil
1175 }
1176
1177 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1178 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1179 cmd.Dir = a.repoRoot
1180 output, err := cmd.CombinedOutput()
1181 if err != nil {
1182 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1183 }
1184
1185 return string(output), nil
1186}
1187
1188// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1189func (a *Agent) InitialCommit() string {
1190 return a.initialCommit
1191}
1192
1193// handleGitCommits() highlights new commits to the user. When running
1194// under docker, new HEADs are pushed to a branch according to the title.
1195func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1196 if a.repoRoot == "" {
1197 return nil, nil
1198 }
1199
1200 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1201 if err != nil {
1202 return nil, err
1203 }
1204 if head == a.lastHEAD {
1205 return nil, nil // nothing to do
1206 }
1207 defer func() {
1208 a.lastHEAD = head
1209 }()
1210
1211 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1212 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1213 // to the last 100 commits.
1214 var commits []*GitCommit
1215
1216 // Get commits since the initial commit
1217 // Format: <hash>\0<subject>\0<body>\0
1218 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1219 // Limit to 100 commits to avoid overwhelming the user
1220 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1221 cmd.Dir = a.repoRoot
1222 output, err := cmd.Output()
1223 if err != nil {
1224 return nil, fmt.Errorf("failed to get git log: %w", err)
1225 }
1226
1227 // Parse git log output and filter out already seen commits
1228 parsedCommits := parseGitLog(string(output))
1229
1230 var headCommit *GitCommit
1231
1232 // Filter out commits we've already seen
1233 for _, commit := range parsedCommits {
1234 if commit.Hash == head {
1235 headCommit = &commit
1236 }
1237
1238 // Skip if we've seen this commit before. If our head has changed, always include that.
1239 if a.seenCommits[commit.Hash] && commit.Hash != head {
1240 continue
1241 }
1242
1243 // Mark this commit as seen
1244 a.seenCommits[commit.Hash] = true
1245
1246 // Add to our list of new commits
1247 commits = append(commits, &commit)
1248 }
1249
1250 if a.gitRemoteAddr != "" {
1251 if headCommit == nil {
1252 // I think this can only happen if we have a bug or if there's a race.
1253 headCommit = &GitCommit{}
1254 headCommit.Hash = head
1255 headCommit.Subject = "unknown"
1256 commits = append(commits, headCommit)
1257 }
1258
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001259 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001260
1261 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1262 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1263 // then use push with lease to replace.
1264 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1265 cmd.Dir = a.workingDir
1266 if out, err := cmd.CombinedOutput(); err != nil {
1267 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1268 } else {
1269 headCommit.PushedBranch = branch
1270 }
1271 }
1272
1273 // If we found new commits, create a message
1274 if len(commits) > 0 {
1275 msg := AgentMessage{
1276 Type: CommitMessageType,
1277 Timestamp: time.Now(),
1278 Commits: commits,
1279 }
1280 a.pushToOutbox(ctx, msg)
1281 }
1282 return commits, nil
1283}
1284
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001285func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001286 return strings.Map(func(r rune) rune {
1287 // lowercase
1288 if r >= 'A' && r <= 'Z' {
1289 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001290 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001291 // replace spaces with dashes
1292 if r == ' ' {
1293 return '-'
1294 }
1295 // allow alphanumerics and dashes
1296 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1297 return r
1298 }
1299 return -1
1300 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001301}
1302
1303// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1304// and returns an array of GitCommit structs.
1305func parseGitLog(output string) []GitCommit {
1306 var commits []GitCommit
1307
1308 // No output means no commits
1309 if len(output) == 0 {
1310 return commits
1311 }
1312
1313 // Split by NULL byte
1314 parts := strings.Split(output, "\x00")
1315
1316 // Process in triplets (hash, subject, body)
1317 for i := 0; i < len(parts); i++ {
1318 // Skip empty parts
1319 if parts[i] == "" {
1320 continue
1321 }
1322
1323 // This should be a hash
1324 hash := strings.TrimSpace(parts[i])
1325
1326 // Make sure we have at least a subject part available
1327 if i+1 >= len(parts) {
1328 break // No more parts available
1329 }
1330
1331 // Get the subject
1332 subject := strings.TrimSpace(parts[i+1])
1333
1334 // Get the body if available
1335 body := ""
1336 if i+2 < len(parts) {
1337 body = strings.TrimSpace(parts[i+2])
1338 }
1339
1340 // Skip to the next triplet
1341 i += 2
1342
1343 commits = append(commits, GitCommit{
1344 Hash: hash,
1345 Subject: subject,
1346 Body: body,
1347 })
1348 }
1349
1350 return commits
1351}
1352
1353func repoRoot(ctx context.Context, dir string) (string, error) {
1354 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1355 stderr := new(strings.Builder)
1356 cmd.Stderr = stderr
1357 cmd.Dir = dir
1358 out, err := cmd.Output()
1359 if err != nil {
1360 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1361 }
1362 return strings.TrimSpace(string(out)), nil
1363}
1364
1365func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1366 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1367 stderr := new(strings.Builder)
1368 cmd.Stderr = stderr
1369 cmd.Dir = dir
1370 out, err := cmd.Output()
1371 if err != nil {
1372 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1373 }
1374 // TODO: validate that out is valid hex
1375 return strings.TrimSpace(string(out)), nil
1376}
1377
1378// isValidGitSHA validates if a string looks like a valid git SHA hash.
1379// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1380func isValidGitSHA(sha string) bool {
1381 // Git SHA must be a hexadecimal string with at least 4 characters
1382 if len(sha) < 4 || len(sha) > 40 {
1383 return false
1384 }
1385
1386 // Check if the string only contains hexadecimal characters
1387 for _, char := range sha {
1388 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1389 return false
1390 }
1391 }
1392
1393 return true
1394}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001395
1396// getGitOrigin returns the URL of the git remote 'origin' if it exists
1397func getGitOrigin(ctx context.Context, dir string) string {
1398 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1399 cmd.Dir = dir
1400 stderr := new(strings.Builder)
1401 cmd.Stderr = stderr
1402 out, err := cmd.Output()
1403 if err != nil {
1404 return ""
1405 }
1406 return strings.TrimSpace(string(out))
1407}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001408
1409func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1410 cmd := exec.CommandContext(ctx, "git", "stash")
1411 cmd.Dir = workingDir
1412 if out, err := cmd.CombinedOutput(); err != nil {
1413 return fmt.Errorf("git stash: %s: %v", out, err)
1414 }
1415 cmd = exec.CommandContext(ctx, "git", "fetch", "sketch-host")
1416 cmd.Dir = workingDir
1417 if out, err := cmd.CombinedOutput(); err != nil {
1418 return fmt.Errorf("git fetch: %s: %w", out, err)
1419 }
1420 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1421 cmd.Dir = workingDir
1422 if out, err := cmd.CombinedOutput(); err != nil {
1423 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1424 }
1425 a.lastHEAD = revision
1426 a.initialCommit = revision
1427 return nil
1428}
1429
1430func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1431 a.mu.Lock()
1432 a.title = ""
1433 a.firstMessageIndex = len(a.history)
1434 a.convo = a.initConvo()
1435 gitReset := func() error {
1436 if a.config.InDocker && rev != "" {
1437 err := a.initGitRevision(ctx, a.workingDir, rev)
1438 if err != nil {
1439 return err
1440 }
1441 } else if !a.config.InDocker && rev != "" {
1442 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1443 }
1444 return nil
1445 }
1446 err := gitReset()
1447 a.mu.Unlock()
1448 if err != nil {
1449 a.pushToOutbox(a.config.Context, errorMessage(err))
1450 }
1451
1452 a.pushToOutbox(a.config.Context, AgentMessage{
1453 Type: AgentMessageType, Content: "Conversation restarted.",
1454 })
1455 if initialPrompt != "" {
1456 a.UserMessage(ctx, initialPrompt)
1457 }
1458 return nil
1459}
1460
1461func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1462 msg := `The user has requested a suggestion for a re-prompt.
1463
1464 Given the current conversation thus far, suggest a re-prompt that would
1465 capture the instructions and feedback so far, as well as any
1466 research or other information that would be helpful in implementing
1467 the task.
1468
1469 Reply with ONLY the reprompt text.
1470 `
1471 userMessage := ant.Message{
1472 Role: "user",
1473 Content: []ant.Content{{Type: "text", Text: msg}},
1474 }
1475 // By doing this in a subconversation, the agent doesn't call tools (because
1476 // there aren't any), and there's not a concurrency risk with on-going other
1477 // outstanding conversations.
1478 convo := a.convo.SubConvoWithHistory()
1479 resp, err := convo.SendMessage(userMessage)
1480 if err != nil {
1481 a.pushToOutbox(ctx, errorMessage(err))
1482 return "", err
1483 }
1484 textContent := collectTextContent(resp)
1485 return textContent, nil
1486}