blob: 960bf5a66ba817938121a0833624c72d8d130a48 [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"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00009 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "log/slog"
11 "net/http"
12 "os"
13 "os/exec"
14 "runtime/debug"
15 "slices"
16 "strings"
17 "sync"
18 "time"
19
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000020 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070021 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000022 "sketch.dev/claudetool/bashkit"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070023 "sketch.dev/llm"
24 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070025)
26
27const (
28 userCancelMessage = "user requested agent to stop handling responses"
29)
30
Philip Zeyligerb7c58752025-05-01 10:10:17 -070031type MessageIterator interface {
32 // Next blocks until the next message is available. It may
33 // return nil if the underlying iterator context is done.
34 Next() *AgentMessage
35 Close()
36}
37
Earl Lee2e463fb2025-04-17 11:22:22 -070038type CodingAgent interface {
39 // Init initializes an agent inside a docker container.
40 Init(AgentInit) error
41
42 // Ready returns a channel closed after Init successfully called.
43 Ready() <-chan struct{}
44
45 // URL reports the HTTP URL of this agent.
46 URL() string
47
48 // UserMessage enqueues a message to the agent and returns immediately.
49 UserMessage(ctx context.Context, msg string)
50
Philip Zeyligerb7c58752025-05-01 10:10:17 -070051 // Returns an iterator that finishes when the context is done and
52 // starts with the given message index.
53 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070054
55 // Loop begins the agent loop returns only when ctx is cancelled.
56 Loop(ctx context.Context)
57
Sean McCulloughedc88dc2025-04-30 02:55:01 +000058 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070059
60 CancelToolUse(toolUseID string, cause error) error
61
62 // Returns a subset of the agent's message history.
63 Messages(start int, end int) []AgentMessage
64
65 // Returns the current number of messages in the history
66 MessageCount() int
67
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070068 TotalUsage() conversation.CumulativeUsage
69 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070070
Earl Lee2e463fb2025-04-17 11:22:22 -070071 WorkingDir() string
72
73 // Diff returns a unified diff of changes made since the agent was instantiated.
74 // If commit is non-nil, it shows the diff for just that specific commit.
75 Diff(commit *string) (string, error)
76
77 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
78 InitialCommit() string
79
80 // Title returns the current title of the conversation.
81 Title() string
82
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000083 // BranchName returns the git branch name for the conversation.
84 BranchName() string
85
Earl Lee2e463fb2025-04-17 11:22:22 -070086 // OS returns the operating system of the client.
87 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000088
Philip Zeyligerc72fff52025-04-29 20:17:54 +000089 // SessionID returns the unique session identifier.
90 SessionID() string
91
Philip Zeyliger99a9a022025-04-27 15:15:25 +000092 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
93 OutstandingLLMCallCount() int
94
95 // OutstandingToolCalls returns the names of outstanding tool calls.
96 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +000097 OutsideOS() string
98 OutsideHostname() string
99 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000100 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000101 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
102 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700103
104 // RestartConversation resets the conversation history
105 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
106 // SuggestReprompt suggests a re-prompt based on the current conversation.
107 SuggestReprompt(ctx context.Context) (string, error)
108 // IsInContainer returns true if the agent is running in a container
109 IsInContainer() bool
110 // FirstMessageIndex returns the index of the first message in the current conversation
111 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700112
113 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700114}
115
116type CodingAgentMessageType string
117
118const (
119 UserMessageType CodingAgentMessageType = "user"
120 AgentMessageType CodingAgentMessageType = "agent"
121 ErrorMessageType CodingAgentMessageType = "error"
122 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
123 ToolUseMessageType CodingAgentMessageType = "tool"
124 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
125 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
126
127 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
128)
129
130type AgentMessage struct {
131 Type CodingAgentMessageType `json:"type"`
132 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
133 EndOfTurn bool `json:"end_of_turn"`
134
135 Content string `json:"content"`
136 ToolName string `json:"tool_name,omitempty"`
137 ToolInput string `json:"input,omitempty"`
138 ToolResult string `json:"tool_result,omitempty"`
139 ToolError bool `json:"tool_error,omitempty"`
140 ToolCallId string `json:"tool_call_id,omitempty"`
141
142 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
143 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
144
Sean McCulloughd9f13372025-04-21 15:08:49 -0700145 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
146 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
147
Earl Lee2e463fb2025-04-17 11:22:22 -0700148 // Commits is a list of git commits for a commit message
149 Commits []*GitCommit `json:"commits,omitempty"`
150
151 Timestamp time.Time `json:"timestamp"`
152 ConversationID string `json:"conversation_id"`
153 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700154 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700155
156 // Message timing information
157 StartTime *time.Time `json:"start_time,omitempty"`
158 EndTime *time.Time `json:"end_time,omitempty"`
159 Elapsed *time.Duration `json:"elapsed,omitempty"`
160
161 // Turn duration - the time taken for a complete agent turn
162 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
163
164 Idx int `json:"idx"`
165}
166
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700167// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700168func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700169 if convo == nil {
170 m.ConversationID = ""
171 m.ParentConversationID = nil
172 return
173 }
174 m.ConversationID = convo.ID
175 if convo.Parent != nil {
176 m.ParentConversationID = &convo.Parent.ID
177 }
178}
179
Earl Lee2e463fb2025-04-17 11:22:22 -0700180// GitCommit represents a single git commit for a commit message
181type GitCommit struct {
182 Hash string `json:"hash"` // Full commit hash
183 Subject string `json:"subject"` // Commit subject line
184 Body string `json:"body"` // Full commit message body
185 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
186}
187
188// ToolCall represents a single tool call within an agent message
189type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700190 Name string `json:"name"`
191 Input string `json:"input"`
192 ToolCallId string `json:"tool_call_id"`
193 ResultMessage *AgentMessage `json:"result_message,omitempty"`
194 Args string `json:"args,omitempty"`
195 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700196}
197
198func (a *AgentMessage) Attr() slog.Attr {
199 var attrs []any = []any{
200 slog.String("type", string(a.Type)),
201 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700202 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700203 if a.EndOfTurn {
204 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
205 }
206 if a.Content != "" {
207 attrs = append(attrs, slog.String("content", a.Content))
208 }
209 if a.ToolName != "" {
210 attrs = append(attrs, slog.String("tool_name", a.ToolName))
211 }
212 if a.ToolInput != "" {
213 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
214 }
215 if a.Elapsed != nil {
216 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
217 }
218 if a.TurnDuration != nil {
219 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
220 }
221 if a.ToolResult != "" {
222 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
223 }
224 if a.ToolError {
225 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
226 }
227 if len(a.ToolCalls) > 0 {
228 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
229 for i, tc := range a.ToolCalls {
230 toolCallAttrs = append(toolCallAttrs, slog.Group(
231 fmt.Sprintf("tool_call_%d", i),
232 slog.String("name", tc.Name),
233 slog.String("input", tc.Input),
234 ))
235 }
236 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
237 }
238 if a.ConversationID != "" {
239 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
240 }
241 if a.ParentConversationID != nil {
242 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
243 }
244 if a.Usage != nil && !a.Usage.IsZero() {
245 attrs = append(attrs, a.Usage.Attr())
246 }
247 // TODO: timestamp, convo ids, idx?
248 return slog.Group("agent_message", attrs...)
249}
250
251func errorMessage(err error) AgentMessage {
252 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
253 if os.Getenv(("DEBUG")) == "1" {
254 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
255 }
256
257 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
258}
259
260func budgetMessage(err error) AgentMessage {
261 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
262}
263
264// ConvoInterface defines the interface for conversation interactions
265type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700266 CumulativeUsage() conversation.CumulativeUsage
267 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700268 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700269 SendMessage(message llm.Message) (*llm.Response, error)
270 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700271 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700272 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
273 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700274 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700275 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700276}
277
278type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700279 convo ConvoInterface
280 config AgentConfig // config for this agent
281 workingDir string
282 repoRoot string // workingDir may be a subdir of repoRoot
283 url string
284 firstMessageIndex int // index of the first message in the current conversation
285 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
286 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
287 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000288 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700289 ready chan struct{} // closed when the agent is initialized (only when under docker)
290 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700291 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700292 title string
293 branchName string
294 codereview *claudetool.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700295 // State machine to track agent state
296 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000297 // Outside information
298 outsideHostname string
299 outsideOS string
300 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000301 // URL of the git remote 'origin' if it exists
302 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700303
304 // Time when the current turn started (reset at the beginning of InnerLoop)
305 startOfTurn time.Time
306
307 // Inbox - for messages from the user to the agent.
308 // sent on by UserMessage
309 // . e.g. when user types into the chat textarea
310 // read from by GatherMessages
311 inbox chan string
312
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000313 // protects cancelTurn
314 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700315 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000316 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700317
318 // protects following
319 mu sync.Mutex
320
321 // Stores all messages for this agent
322 history []AgentMessage
323
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700324 // Iterators add themselves here when they're ready to be notified of new messages.
325 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700326
327 // Track git commits we've already seen (by hash)
328 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000329
330 // Track outstanding LLM call IDs
331 outstandingLLMCalls map[string]struct{}
332
333 // Track outstanding tool calls by ID with their names
334 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700335}
336
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700337// NewIterator implements CodingAgent.
338func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
339 a.mu.Lock()
340 defer a.mu.Unlock()
341
342 return &MessageIteratorImpl{
343 agent: a,
344 ctx: ctx,
345 nextMessageIdx: nextMessageIdx,
346 ch: make(chan *AgentMessage, 100),
347 }
348}
349
350type MessageIteratorImpl struct {
351 agent *Agent
352 ctx context.Context
353 nextMessageIdx int
354 ch chan *AgentMessage
355 subscribed bool
356}
357
358func (m *MessageIteratorImpl) Close() {
359 m.agent.mu.Lock()
360 defer m.agent.mu.Unlock()
361 // Delete ourselves from the subscribers list
362 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
363 return x == m.ch
364 })
365 close(m.ch)
366}
367
368func (m *MessageIteratorImpl) Next() *AgentMessage {
369 // We avoid subscription at creation to let ourselves catch up to "current state"
370 // before subscribing.
371 if !m.subscribed {
372 m.agent.mu.Lock()
373 if m.nextMessageIdx < len(m.agent.history) {
374 msg := &m.agent.history[m.nextMessageIdx]
375 m.nextMessageIdx++
376 m.agent.mu.Unlock()
377 return msg
378 }
379 // The next message doesn't exist yet, so let's subscribe
380 m.agent.subscribers = append(m.agent.subscribers, m.ch)
381 m.subscribed = true
382 m.agent.mu.Unlock()
383 }
384
385 for {
386 select {
387 case <-m.ctx.Done():
388 m.agent.mu.Lock()
389 // Delete ourselves from the subscribers list
390 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
391 return x == m.ch
392 })
393 m.subscribed = false
394 m.agent.mu.Unlock()
395 return nil
396 case msg, ok := <-m.ch:
397 if !ok {
398 // Close may have been called
399 return nil
400 }
401 if msg.Idx == m.nextMessageIdx {
402 m.nextMessageIdx++
403 return msg
404 }
405 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
406 panic("out of order message")
407 }
408 }
409}
410
Sean McCulloughd9d45812025-04-30 16:53:41 -0700411// Assert that Agent satisfies the CodingAgent interface.
412var _ CodingAgent = &Agent{}
413
414// StateName implements CodingAgent.
415func (a *Agent) CurrentStateName() string {
416 if a.stateMachine == nil {
417 return ""
418 }
419 return a.stateMachine.currentState.String()
420}
421
Earl Lee2e463fb2025-04-17 11:22:22 -0700422func (a *Agent) URL() string { return a.url }
423
424// Title returns the current title of the conversation.
425// If no title has been set, returns an empty string.
426func (a *Agent) Title() string {
427 a.mu.Lock()
428 defer a.mu.Unlock()
429 return a.title
430}
431
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000432// BranchName returns the git branch name for the conversation.
433func (a *Agent) BranchName() string {
434 a.mu.Lock()
435 defer a.mu.Unlock()
436 return a.branchName
437}
438
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000439// OutstandingLLMCallCount returns the number of outstanding LLM calls.
440func (a *Agent) OutstandingLLMCallCount() int {
441 a.mu.Lock()
442 defer a.mu.Unlock()
443 return len(a.outstandingLLMCalls)
444}
445
446// OutstandingToolCalls returns the names of outstanding tool calls.
447func (a *Agent) OutstandingToolCalls() []string {
448 a.mu.Lock()
449 defer a.mu.Unlock()
450
451 tools := make([]string, 0, len(a.outstandingToolCalls))
452 for _, toolName := range a.outstandingToolCalls {
453 tools = append(tools, toolName)
454 }
455 return tools
456}
457
Earl Lee2e463fb2025-04-17 11:22:22 -0700458// OS returns the operating system of the client.
459func (a *Agent) OS() string {
460 return a.config.ClientGOOS
461}
462
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000463func (a *Agent) SessionID() string {
464 return a.config.SessionID
465}
466
Philip Zeyliger18532b22025-04-23 21:11:46 +0000467// OutsideOS returns the operating system of the outside system.
468func (a *Agent) OutsideOS() string {
469 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000470}
471
Philip Zeyliger18532b22025-04-23 21:11:46 +0000472// OutsideHostname returns the hostname of the outside system.
473func (a *Agent) OutsideHostname() string {
474 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000475}
476
Philip Zeyliger18532b22025-04-23 21:11:46 +0000477// OutsideWorkingDir returns the working directory on the outside system.
478func (a *Agent) OutsideWorkingDir() string {
479 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000480}
481
482// GitOrigin returns the URL of the git remote 'origin' if it exists.
483func (a *Agent) GitOrigin() string {
484 return a.gitOrigin
485}
486
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000487func (a *Agent) OpenBrowser(url string) {
488 if !a.IsInContainer() {
489 browser.Open(url)
490 return
491 }
492 // We're in Docker, need to send a request to the Git server
493 // to signal that the outer process should open the browser.
494 httpc := &http.Client{Timeout: 5 * time.Second}
495 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", strings.NewReader(url))
496 if err != nil {
497 slog.Debug("browser launch request connection failed", "err", err, "url", url)
498 return
499 }
500 defer resp.Body.Close()
501 if resp.StatusCode == http.StatusOK {
502 return
503 }
504 body, _ := io.ReadAll(resp.Body)
505 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
506}
507
Sean McCullough96b60dd2025-04-30 09:49:10 -0700508// CurrentState returns the current state of the agent's state machine.
509func (a *Agent) CurrentState() State {
510 return a.stateMachine.CurrentState()
511}
512
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700513func (a *Agent) IsInContainer() bool {
514 return a.config.InDocker
515}
516
517func (a *Agent) FirstMessageIndex() int {
518 a.mu.Lock()
519 defer a.mu.Unlock()
520 return a.firstMessageIndex
521}
522
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700523// SetTitleBranch sets the title and branch name of the conversation.
524func (a *Agent) SetTitleBranch(title, branchName string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700525 a.mu.Lock()
526 defer a.mu.Unlock()
527 a.title = title
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700528 a.branchName = branchName
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700529
530 // TODO: We could potentially notify listeners of a state change, but,
531 // realistically, a new message will be sent for the tool result as well.
Earl Lee2e463fb2025-04-17 11:22:22 -0700532}
533
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000534// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700535func (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 +0000536 // Track the tool call
537 a.mu.Lock()
538 a.outstandingToolCalls[id] = toolName
539 a.mu.Unlock()
540}
541
Earl Lee2e463fb2025-04-17 11:22:22 -0700542// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700543func (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 +0000544 // Remove the tool call from outstanding calls
545 a.mu.Lock()
546 delete(a.outstandingToolCalls, toolID)
547 a.mu.Unlock()
548
Earl Lee2e463fb2025-04-17 11:22:22 -0700549 m := AgentMessage{
550 Type: ToolUseMessageType,
551 Content: content.Text,
552 ToolResult: content.ToolResult,
553 ToolError: content.ToolError,
554 ToolName: toolName,
555 ToolInput: string(toolInput),
556 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700557 StartTime: content.ToolUseStartTime,
558 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700559 }
560
561 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700562 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
563 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700564 m.Elapsed = &elapsed
565 }
566
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700567 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700568 a.pushToOutbox(ctx, m)
569}
570
571// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700572func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000573 a.mu.Lock()
574 defer a.mu.Unlock()
575 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700576 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
577}
578
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700579// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700580// that need to be displayed (as well as tool calls that we send along when
581// they're done). (It would be reasonable to also mention tool calls when they're
582// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700583func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000584 // Remove the LLM call from outstanding calls
585 a.mu.Lock()
586 delete(a.outstandingLLMCalls, id)
587 a.mu.Unlock()
588
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700589 if resp == nil {
590 // LLM API call failed
591 m := AgentMessage{
592 Type: ErrorMessageType,
593 Content: "API call failed, type 'continue' to try again",
594 }
595 m.SetConvo(convo)
596 a.pushToOutbox(ctx, m)
597 return
598 }
599
Earl Lee2e463fb2025-04-17 11:22:22 -0700600 endOfTurn := false
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700601 if resp.StopReason != llm.StopReasonToolUse && convo.Parent == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700602 endOfTurn = true
603 }
604 m := AgentMessage{
605 Type: AgentMessageType,
606 Content: collectTextContent(resp),
607 EndOfTurn: endOfTurn,
608 Usage: &resp.Usage,
609 StartTime: resp.StartTime,
610 EndTime: resp.EndTime,
611 }
612
613 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700614 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700615 var toolCalls []ToolCall
616 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700617 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700618 toolCalls = append(toolCalls, ToolCall{
619 Name: part.ToolName,
620 Input: string(part.ToolInput),
621 ToolCallId: part.ID,
622 })
623 }
624 }
625 m.ToolCalls = toolCalls
626 }
627
628 // Calculate the elapsed time if both start and end times are set
629 if resp.StartTime != nil && resp.EndTime != nil {
630 elapsed := resp.EndTime.Sub(*resp.StartTime)
631 m.Elapsed = &elapsed
632 }
633
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700634 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700635 a.pushToOutbox(ctx, m)
636}
637
638// WorkingDir implements CodingAgent.
639func (a *Agent) WorkingDir() string {
640 return a.workingDir
641}
642
643// MessageCount implements CodingAgent.
644func (a *Agent) MessageCount() int {
645 a.mu.Lock()
646 defer a.mu.Unlock()
647 return len(a.history)
648}
649
650// Messages implements CodingAgent.
651func (a *Agent) Messages(start int, end int) []AgentMessage {
652 a.mu.Lock()
653 defer a.mu.Unlock()
654 return slices.Clone(a.history[start:end])
655}
656
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700657func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700658 return a.originalBudget
659}
660
661// AgentConfig contains configuration for creating a new Agent.
662type AgentConfig struct {
663 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700664 Service llm.Service
665 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700666 GitUsername string
667 GitEmail string
668 SessionID string
669 ClientGOOS string
670 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700671 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700672 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000673 // Outside information
674 OutsideHostname string
675 OutsideOS string
676 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700677}
678
679// NewAgent creates a new Agent.
680// It is not usable until Init() is called.
681func NewAgent(config AgentConfig) *Agent {
682 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000683 config: config,
684 ready: make(chan struct{}),
685 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700686 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000687 startedAt: time.Now(),
688 originalBudget: config.Budget,
689 seenCommits: make(map[string]bool),
690 outsideHostname: config.OutsideHostname,
691 outsideOS: config.OutsideOS,
692 outsideWorkingDir: config.OutsideWorkingDir,
693 outstandingLLMCalls: make(map[string]struct{}),
694 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700695 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700696 }
697 return agent
698}
699
700type AgentInit struct {
701 WorkingDir string
702 NoGit bool // only for testing
703
704 InDocker bool
705 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000706 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700707 GitRemoteAddr string
708 HostAddr string
709}
710
711func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700712 if a.convo != nil {
713 return fmt.Errorf("Agent.Init: already initialized")
714 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700715 ctx := a.config.Context
716 if ini.InDocker {
717 cmd := exec.CommandContext(ctx, "git", "stash")
718 cmd.Dir = ini.WorkingDir
719 if out, err := cmd.CombinedOutput(); err != nil {
720 return fmt.Errorf("git stash: %s: %v", out, err)
721 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700722 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
723 cmd.Dir = ini.WorkingDir
724 if out, err := cmd.CombinedOutput(); err != nil {
725 return fmt.Errorf("git remote add: %s: %v", out, err)
726 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000727 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700728 cmd.Dir = ini.WorkingDir
729 if out, err := cmd.CombinedOutput(); err != nil {
730 return fmt.Errorf("git fetch: %s: %w", out, err)
731 }
732 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
733 cmd.Dir = ini.WorkingDir
734 if out, err := cmd.CombinedOutput(); err != nil {
735 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
736 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700737 a.lastHEAD = ini.Commit
738 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000739 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700740 a.initialCommit = ini.Commit
741 if ini.HostAddr != "" {
742 a.url = "http://" + ini.HostAddr
743 }
744 }
745 a.workingDir = ini.WorkingDir
746
747 if !ini.NoGit {
748 repoRoot, err := repoRoot(ctx, a.workingDir)
749 if err != nil {
750 return fmt.Errorf("repoRoot: %w", err)
751 }
752 a.repoRoot = repoRoot
753
754 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
755 if err != nil {
756 return fmt.Errorf("resolveRef: %w", err)
757 }
758 a.initialCommit = commitHash
759
760 codereview, err := claudetool.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit)
761 if err != nil {
762 return fmt.Errorf("Agent.Init: claudetool.NewCodeReviewer: %w", err)
763 }
764 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000765
766 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700767 }
768 a.lastHEAD = a.initialCommit
769 a.convo = a.initConvo()
770 close(a.ready)
771 return nil
772}
773
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700774//go:embed agent_system_prompt.txt
775var agentSystemPrompt string
776
Earl Lee2e463fb2025-04-17 11:22:22 -0700777// initConvo initializes the conversation.
778// It must not be called until all agent fields are initialized,
779// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700780func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700781 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700782 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700783 convo.PromptCaching = true
784 convo.Budget = a.config.Budget
785
786 var editPrompt string
787 if a.config.UseAnthropicEdit {
788 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."
789 } else {
790 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
791 }
792
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700793 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 -0700794
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000795 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
796 bashPermissionCheck := func(command string) error {
797 // Check if branch name is set
798 a.mu.Lock()
799 branchSet := a.branchName != ""
800 a.mu.Unlock()
801
802 // If branch is set, all commands are allowed
803 if branchSet {
804 return nil
805 }
806
807 // If branch is not set, check if this is a git commit command
808 willCommit, err := bashkit.WillRunGitCommit(command)
809 if err != nil {
810 // If there's an error checking, we should allow the command to proceed
811 return nil
812 }
813
814 // If it's a git commit and branch is not set, return an error
815 if willCommit {
816 return fmt.Errorf("you must use the title tool before making git commits")
817 }
818
819 return nil
820 }
821
822 // Create a custom bash tool with the permission check
823 bashTool := claudetool.NewBashTool(bashPermissionCheck)
824
Earl Lee2e463fb2025-04-17 11:22:22 -0700825 // Register all tools with the conversation
826 // When adding, removing, or modifying tools here, double-check that the termui tool display
827 // template in termui/termui.go has pretty-printing support for all tools.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700828 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000829 bashTool, claudetool.Keyword,
Earl Lee2e463fb2025-04-17 11:22:22 -0700830 claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
831 a.codereview.Tool(),
832 }
833 if a.config.UseAnthropicEdit {
834 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
835 } else {
836 convo.Tools = append(convo.Tools, claudetool.Patch)
837 }
838 convo.Listener = a
839 return convo
840}
841
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000842// branchExists reports whether branchName exists, either locally or in well-known remotes.
843func branchExists(dir, branchName string) bool {
844 refs := []string{
845 "refs/heads/",
846 "refs/remotes/origin/",
847 "refs/remotes/sketch-host/",
848 }
849 for _, ref := range refs {
850 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
851 cmd.Dir = dir
852 if cmd.Run() == nil { // exit code 0 means branch exists
853 return true
854 }
855 }
856 return false
857}
858
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700859func (a *Agent) titleTool() *llm.Tool {
860 title := &llm.Tool{
Earl Lee2e463fb2025-04-17 11:22:22 -0700861 Name: "title",
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700862 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 -0700863 InputSchema: json.RawMessage(`{
864 "type": "object",
865 "properties": {
866 "title": {
867 "type": "string",
Josh Bleecher Snyder250348e2025-04-30 10:31:28 -0700868 "description": "A concise title summarizing what this conversation is about, imperative tense preferred"
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700869 },
870 "branch_name": {
871 "type": "string",
872 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700873 }
874 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700875 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700876}`),
877 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
878 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700879 Title string `json:"title"`
880 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700881 }
882 if err := json.Unmarshal(input, &params); err != nil {
883 return "", err
884 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700885 // It's unfortunate to not allow title changes,
886 // but it avoids having multiple branches.
887 t := a.Title()
888 if t != "" {
889 return "", fmt.Errorf("title already set to: %s", t)
890 }
891
892 if params.BranchName == "" {
893 return "", fmt.Errorf("branch_name parameter cannot be empty")
894 }
895 if params.Title == "" {
896 return "", fmt.Errorf("title parameter cannot be empty")
897 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -0700898 if params.BranchName != cleanBranchName(params.BranchName) {
899 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
900 }
901 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000902 if branchExists(a.workingDir, branchName) {
903 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
904 }
905
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700906 a.SetTitleBranch(params.Title, branchName)
907
908 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
909 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700910 },
911 }
912 return title
913}
914
915func (a *Agent) Ready() <-chan struct{} {
916 return a.ready
917}
918
919func (a *Agent) UserMessage(ctx context.Context, msg string) {
920 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
921 a.inbox <- msg
922}
923
Earl Lee2e463fb2025-04-17 11:22:22 -0700924func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
925 return a.convo.CancelToolUse(toolUseID, cause)
926}
927
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000928func (a *Agent) CancelTurn(cause error) {
929 a.cancelTurnMu.Lock()
930 defer a.cancelTurnMu.Unlock()
931 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -0700932 // Force state transition to cancelled state
933 ctx := a.config.Context
934 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000935 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -0700936 }
937}
938
939func (a *Agent) Loop(ctxOuter context.Context) {
940 for {
941 select {
942 case <-ctxOuter.Done():
943 return
944 default:
945 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000946 a.cancelTurnMu.Lock()
947 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +0000948 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000949 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -0700950 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000951 a.cancelTurn = cancel
952 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +0000953 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
954 if err != nil {
955 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
956 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700957 cancel(nil)
958 }
959 }
960}
961
962func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
963 if m.Timestamp.IsZero() {
964 m.Timestamp = time.Now()
965 }
966
967 // If this is an end-of-turn message, calculate the turn duration and add it to the message
968 if m.EndOfTurn && m.Type == AgentMessageType {
969 turnDuration := time.Since(a.startOfTurn)
970 m.TurnDuration = &turnDuration
971 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
972 }
973
Earl Lee2e463fb2025-04-17 11:22:22 -0700974 a.mu.Lock()
975 defer a.mu.Unlock()
976 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700977 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -0700978 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -0700979
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700980 // Notify all subscribers
981 for _, ch := range a.subscribers {
982 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -0700983 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700984}
985
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700986func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
987 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -0700988 if block {
989 select {
990 case <-ctx.Done():
991 return m, ctx.Err()
992 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700993 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -0700994 }
995 }
996 for {
997 select {
998 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700999 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001000 default:
1001 return m, nil
1002 }
1003 }
1004}
1005
Sean McCullough885a16a2025-04-30 02:49:25 +00001006// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001007func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001008 // Reset the start of turn time
1009 a.startOfTurn = time.Now()
1010
Sean McCullough96b60dd2025-04-30 09:49:10 -07001011 // Transition to waiting for user input state
1012 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1013
Sean McCullough885a16a2025-04-30 02:49:25 +00001014 // Process initial user message
1015 initialResp, err := a.processUserMessage(ctx)
1016 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001017 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001018 return err
1019 }
1020
1021 // Handle edge case where both initialResp and err are nil
1022 if initialResp == nil {
1023 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001024 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1025
Sean McCullough9f4b8082025-04-30 17:34:07 +00001026 a.pushToOutbox(ctx, errorMessage(err))
1027 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001028 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001029
Earl Lee2e463fb2025-04-17 11:22:22 -07001030 // We do this as we go, but let's also do it at the end of the turn
1031 defer func() {
1032 if _, err := a.handleGitCommits(ctx); err != nil {
1033 // Just log the error, don't stop execution
1034 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1035 }
1036 }()
1037
Sean McCullougha1e0e492025-05-01 10:51:08 -07001038 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001039 resp := initialResp
1040 for {
1041 // Check if we are over budget
1042 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001043 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001044 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001045 }
1046
1047 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001048 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001049 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001050 break
1051 }
1052
Sean McCullough96b60dd2025-04-30 09:49:10 -07001053 // Transition to tool use requested state
1054 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1055
Sean McCullough885a16a2025-04-30 02:49:25 +00001056 // Handle tool execution
1057 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1058 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001059 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001060 }
1061
Sean McCullougha1e0e492025-05-01 10:51:08 -07001062 if toolResp == nil {
1063 return fmt.Errorf("cannot continue conversation with a nil tool response")
1064 }
1065
Sean McCullough885a16a2025-04-30 02:49:25 +00001066 // Set the response for the next iteration
1067 resp = toolResp
1068 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001069
1070 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001071}
1072
1073// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001074func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001075 // Wait for at least one message from the user
1076 msgs, err := a.GatherMessages(ctx, true)
1077 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001078 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001079 return nil, err
1080 }
1081
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001082 userMessage := llm.Message{
1083 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001084 Content: msgs,
1085 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001086
Sean McCullough96b60dd2025-04-30 09:49:10 -07001087 // Transition to sending to LLM state
1088 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1089
Sean McCullough885a16a2025-04-30 02:49:25 +00001090 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001091 resp, err := a.convo.SendMessage(userMessage)
1092 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001093 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001094 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001095 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001096 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001097
Sean McCullough96b60dd2025-04-30 09:49:10 -07001098 // Transition to processing LLM response state
1099 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1100
Sean McCullough885a16a2025-04-30 02:49:25 +00001101 return resp, nil
1102}
1103
1104// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001105func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1106 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001107 cancelled := false
1108
Sean McCullough96b60dd2025-04-30 09:49:10 -07001109 // Transition to checking for cancellation state
1110 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1111
Sean McCullough885a16a2025-04-30 02:49:25 +00001112 // Check if the operation was cancelled by the user
1113 select {
1114 case <-ctx.Done():
1115 // Don't actually run any of the tools, but rather build a response
1116 // for each tool_use message letting the LLM know that user canceled it.
1117 var err error
1118 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001119 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001120 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001121 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001122 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001123 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001124 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001125 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001126 // Transition to running tool state
1127 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1128
Sean McCullough885a16a2025-04-30 02:49:25 +00001129 // Add working directory to context for tool execution
1130 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1131
1132 // Execute the tools
1133 var err error
1134 results, err = a.convo.ToolResultContents(ctx, resp)
1135 if ctx.Err() != nil { // e.g. the user canceled the operation
1136 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001137 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001138 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001139 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001140 a.pushToOutbox(ctx, errorMessage(err))
1141 }
1142 }
1143
1144 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001145 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001146 autoqualityMessages := a.processGitChanges(ctx)
1147
1148 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001149 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001150 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001151 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001152 return false, nil
1153 }
1154
1155 // Continue the conversation with tool results and any user messages
1156 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1157}
1158
1159// processGitChanges checks for new git commits and runs autoformatters if needed
1160func (a *Agent) processGitChanges(ctx context.Context) []string {
1161 // Check for git commits after tool execution
1162 newCommits, err := a.handleGitCommits(ctx)
1163 if err != nil {
1164 // Just log the error, don't stop execution
1165 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1166 return nil
1167 }
1168
1169 // Run autoformatters if there was exactly one new commit
1170 var autoqualityMessages []string
1171 if len(newCommits) == 1 {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001172 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running autoformatters on new commit")
Sean McCullough885a16a2025-04-30 02:49:25 +00001173 formatted := a.codereview.Autoformat(ctx)
1174 if len(formatted) > 0 {
1175 msg := fmt.Sprintf(`
Earl Lee2e463fb2025-04-17 11:22:22 -07001176I ran autoformatters and they updated these files:
1177
1178%s
1179
1180Please amend your latest git commit with these changes and then continue with what you were doing.`,
Sean McCullough885a16a2025-04-30 02:49:25 +00001181 strings.Join(formatted, "\n"),
1182 )[1:]
1183 a.pushToOutbox(ctx, AgentMessage{
1184 Type: AutoMessageType,
1185 Content: msg,
1186 Timestamp: time.Now(),
1187 })
1188 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001189 }
1190 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001191
1192 return autoqualityMessages
1193}
1194
1195// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001196func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001197 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001198 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001199 msgs, err := a.GatherMessages(ctx, false)
1200 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001201 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001202 return false, nil
1203 }
1204
1205 // Inject any auto-generated messages from quality checks
1206 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001207 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001208 }
1209
1210 // Handle cancellation by appending a message about it
1211 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001212 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001213 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001214 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001215 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1216 } else if err := a.convo.OverBudget(); err != nil {
1217 // Handle budget issues by appending a message about it
1218 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 -07001219 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001220 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1221 }
1222
1223 // Combine tool results with user messages
1224 results = append(results, msgs...)
1225
1226 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001227 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001228 resp, err := a.convo.SendMessage(llm.Message{
1229 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001230 Content: results,
1231 })
1232 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001233 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001234 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1235 return true, nil // Return true to continue the conversation, but with no response
1236 }
1237
Sean McCullough96b60dd2025-04-30 09:49:10 -07001238 // Transition back to processing LLM response
1239 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1240
Sean McCullough885a16a2025-04-30 02:49:25 +00001241 if cancelled {
1242 return false, nil
1243 }
1244
1245 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001246}
1247
1248func (a *Agent) overBudget(ctx context.Context) error {
1249 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001250 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001251 m := budgetMessage(err)
1252 m.Content = m.Content + "\n\nBudget reset."
1253 a.pushToOutbox(ctx, budgetMessage(err))
1254 a.convo.ResetBudget(a.originalBudget)
1255 return err
1256 }
1257 return nil
1258}
1259
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001260func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001261 // Collect all text content
1262 var allText strings.Builder
1263 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001264 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001265 if allText.Len() > 0 {
1266 allText.WriteString("\n\n")
1267 }
1268 allText.WriteString(content.Text)
1269 }
1270 }
1271 return allText.String()
1272}
1273
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001274func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001275 a.mu.Lock()
1276 defer a.mu.Unlock()
1277 return a.convo.CumulativeUsage()
1278}
1279
Earl Lee2e463fb2025-04-17 11:22:22 -07001280// Diff returns a unified diff of changes made since the agent was instantiated.
1281func (a *Agent) Diff(commit *string) (string, error) {
1282 if a.initialCommit == "" {
1283 return "", fmt.Errorf("no initial commit reference available")
1284 }
1285
1286 // Find the repository root
1287 ctx := context.Background()
1288
1289 // If a specific commit hash is provided, show just that commit's changes
1290 if commit != nil && *commit != "" {
1291 // Validate that the commit looks like a valid git SHA
1292 if !isValidGitSHA(*commit) {
1293 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1294 }
1295
1296 // Get the diff for just this commit
1297 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1298 cmd.Dir = a.repoRoot
1299 output, err := cmd.CombinedOutput()
1300 if err != nil {
1301 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1302 }
1303 return string(output), nil
1304 }
1305
1306 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1307 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1308 cmd.Dir = a.repoRoot
1309 output, err := cmd.CombinedOutput()
1310 if err != nil {
1311 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1312 }
1313
1314 return string(output), nil
1315}
1316
1317// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1318func (a *Agent) InitialCommit() string {
1319 return a.initialCommit
1320}
1321
1322// handleGitCommits() highlights new commits to the user. When running
1323// under docker, new HEADs are pushed to a branch according to the title.
1324func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1325 if a.repoRoot == "" {
1326 return nil, nil
1327 }
1328
1329 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1330 if err != nil {
1331 return nil, err
1332 }
1333 if head == a.lastHEAD {
1334 return nil, nil // nothing to do
1335 }
1336 defer func() {
1337 a.lastHEAD = head
1338 }()
1339
1340 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1341 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1342 // to the last 100 commits.
1343 var commits []*GitCommit
1344
1345 // Get commits since the initial commit
1346 // Format: <hash>\0<subject>\0<body>\0
1347 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1348 // Limit to 100 commits to avoid overwhelming the user
1349 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1350 cmd.Dir = a.repoRoot
1351 output, err := cmd.Output()
1352 if err != nil {
1353 return nil, fmt.Errorf("failed to get git log: %w", err)
1354 }
1355
1356 // Parse git log output and filter out already seen commits
1357 parsedCommits := parseGitLog(string(output))
1358
1359 var headCommit *GitCommit
1360
1361 // Filter out commits we've already seen
1362 for _, commit := range parsedCommits {
1363 if commit.Hash == head {
1364 headCommit = &commit
1365 }
1366
1367 // Skip if we've seen this commit before. If our head has changed, always include that.
1368 if a.seenCommits[commit.Hash] && commit.Hash != head {
1369 continue
1370 }
1371
1372 // Mark this commit as seen
1373 a.seenCommits[commit.Hash] = true
1374
1375 // Add to our list of new commits
1376 commits = append(commits, &commit)
1377 }
1378
1379 if a.gitRemoteAddr != "" {
1380 if headCommit == nil {
1381 // I think this can only happen if we have a bug or if there's a race.
1382 headCommit = &GitCommit{}
1383 headCommit.Hash = head
1384 headCommit.Subject = "unknown"
1385 commits = append(commits, headCommit)
1386 }
1387
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001388 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001389
1390 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1391 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1392 // then use push with lease to replace.
1393 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1394 cmd.Dir = a.workingDir
1395 if out, err := cmd.CombinedOutput(); err != nil {
1396 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1397 } else {
1398 headCommit.PushedBranch = branch
1399 }
1400 }
1401
1402 // If we found new commits, create a message
1403 if len(commits) > 0 {
1404 msg := AgentMessage{
1405 Type: CommitMessageType,
1406 Timestamp: time.Now(),
1407 Commits: commits,
1408 }
1409 a.pushToOutbox(ctx, msg)
1410 }
1411 return commits, nil
1412}
1413
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001414func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001415 return strings.Map(func(r rune) rune {
1416 // lowercase
1417 if r >= 'A' && r <= 'Z' {
1418 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001419 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001420 // replace spaces with dashes
1421 if r == ' ' {
1422 return '-'
1423 }
1424 // allow alphanumerics and dashes
1425 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1426 return r
1427 }
1428 return -1
1429 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001430}
1431
1432// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1433// and returns an array of GitCommit structs.
1434func parseGitLog(output string) []GitCommit {
1435 var commits []GitCommit
1436
1437 // No output means no commits
1438 if len(output) == 0 {
1439 return commits
1440 }
1441
1442 // Split by NULL byte
1443 parts := strings.Split(output, "\x00")
1444
1445 // Process in triplets (hash, subject, body)
1446 for i := 0; i < len(parts); i++ {
1447 // Skip empty parts
1448 if parts[i] == "" {
1449 continue
1450 }
1451
1452 // This should be a hash
1453 hash := strings.TrimSpace(parts[i])
1454
1455 // Make sure we have at least a subject part available
1456 if i+1 >= len(parts) {
1457 break // No more parts available
1458 }
1459
1460 // Get the subject
1461 subject := strings.TrimSpace(parts[i+1])
1462
1463 // Get the body if available
1464 body := ""
1465 if i+2 < len(parts) {
1466 body = strings.TrimSpace(parts[i+2])
1467 }
1468
1469 // Skip to the next triplet
1470 i += 2
1471
1472 commits = append(commits, GitCommit{
1473 Hash: hash,
1474 Subject: subject,
1475 Body: body,
1476 })
1477 }
1478
1479 return commits
1480}
1481
1482func repoRoot(ctx context.Context, dir string) (string, error) {
1483 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1484 stderr := new(strings.Builder)
1485 cmd.Stderr = stderr
1486 cmd.Dir = dir
1487 out, err := cmd.Output()
1488 if err != nil {
1489 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1490 }
1491 return strings.TrimSpace(string(out)), nil
1492}
1493
1494func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1495 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1496 stderr := new(strings.Builder)
1497 cmd.Stderr = stderr
1498 cmd.Dir = dir
1499 out, err := cmd.Output()
1500 if err != nil {
1501 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1502 }
1503 // TODO: validate that out is valid hex
1504 return strings.TrimSpace(string(out)), nil
1505}
1506
1507// isValidGitSHA validates if a string looks like a valid git SHA hash.
1508// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1509func isValidGitSHA(sha string) bool {
1510 // Git SHA must be a hexadecimal string with at least 4 characters
1511 if len(sha) < 4 || len(sha) > 40 {
1512 return false
1513 }
1514
1515 // Check if the string only contains hexadecimal characters
1516 for _, char := range sha {
1517 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1518 return false
1519 }
1520 }
1521
1522 return true
1523}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001524
1525// getGitOrigin returns the URL of the git remote 'origin' if it exists
1526func getGitOrigin(ctx context.Context, dir string) string {
1527 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1528 cmd.Dir = dir
1529 stderr := new(strings.Builder)
1530 cmd.Stderr = stderr
1531 out, err := cmd.Output()
1532 if err != nil {
1533 return ""
1534 }
1535 return strings.TrimSpace(string(out))
1536}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001537
1538func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1539 cmd := exec.CommandContext(ctx, "git", "stash")
1540 cmd.Dir = workingDir
1541 if out, err := cmd.CombinedOutput(); err != nil {
1542 return fmt.Errorf("git stash: %s: %v", out, err)
1543 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001544 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001545 cmd.Dir = workingDir
1546 if out, err := cmd.CombinedOutput(); err != nil {
1547 return fmt.Errorf("git fetch: %s: %w", out, err)
1548 }
1549 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1550 cmd.Dir = workingDir
1551 if out, err := cmd.CombinedOutput(); err != nil {
1552 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1553 }
1554 a.lastHEAD = revision
1555 a.initialCommit = revision
1556 return nil
1557}
1558
1559func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1560 a.mu.Lock()
1561 a.title = ""
1562 a.firstMessageIndex = len(a.history)
1563 a.convo = a.initConvo()
1564 gitReset := func() error {
1565 if a.config.InDocker && rev != "" {
1566 err := a.initGitRevision(ctx, a.workingDir, rev)
1567 if err != nil {
1568 return err
1569 }
1570 } else if !a.config.InDocker && rev != "" {
1571 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1572 }
1573 return nil
1574 }
1575 err := gitReset()
1576 a.mu.Unlock()
1577 if err != nil {
1578 a.pushToOutbox(a.config.Context, errorMessage(err))
1579 }
1580
1581 a.pushToOutbox(a.config.Context, AgentMessage{
1582 Type: AgentMessageType, Content: "Conversation restarted.",
1583 })
1584 if initialPrompt != "" {
1585 a.UserMessage(ctx, initialPrompt)
1586 }
1587 return nil
1588}
1589
1590func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1591 msg := `The user has requested a suggestion for a re-prompt.
1592
1593 Given the current conversation thus far, suggest a re-prompt that would
1594 capture the instructions and feedback so far, as well as any
1595 research or other information that would be helpful in implementing
1596 the task.
1597
1598 Reply with ONLY the reprompt text.
1599 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001600 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001601 // By doing this in a subconversation, the agent doesn't call tools (because
1602 // there aren't any), and there's not a concurrency risk with on-going other
1603 // outstanding conversations.
1604 convo := a.convo.SubConvoWithHistory()
1605 resp, err := convo.SendMessage(userMessage)
1606 if err != nil {
1607 a.pushToOutbox(ctx, errorMessage(err))
1608 return "", err
1609 }
1610 textContent := collectTextContent(resp)
1611 return textContent, nil
1612}