blob: 315ff89c99fcdbd67d643840590ea5c732445cee [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
20 "sketch.dev/ant"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000021 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070022 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000023 "sketch.dev/claudetool/bashkit"
Earl Lee2e463fb2025-04-17 11:22:22 -070024)
25
26const (
27 userCancelMessage = "user requested agent to stop handling responses"
28)
29
Philip Zeyligerb7c58752025-05-01 10:10:17 -070030type MessageIterator interface {
31 // Next blocks until the next message is available. It may
32 // return nil if the underlying iterator context is done.
33 Next() *AgentMessage
34 Close()
35}
36
Earl Lee2e463fb2025-04-17 11:22:22 -070037type CodingAgent interface {
38 // Init initializes an agent inside a docker container.
39 Init(AgentInit) error
40
41 // Ready returns a channel closed after Init successfully called.
42 Ready() <-chan struct{}
43
44 // URL reports the HTTP URL of this agent.
45 URL() string
46
47 // UserMessage enqueues a message to the agent and returns immediately.
48 UserMessage(ctx context.Context, msg string)
49
Philip Zeyligerb7c58752025-05-01 10:10:17 -070050 // Returns an iterator that finishes when the context is done and
51 // starts with the given message index.
52 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070053
54 // Loop begins the agent loop returns only when ctx is cancelled.
55 Loop(ctx context.Context)
56
Sean McCulloughedc88dc2025-04-30 02:55:01 +000057 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070058
59 CancelToolUse(toolUseID string, cause error) error
60
61 // Returns a subset of the agent's message history.
62 Messages(start int, end int) []AgentMessage
63
64 // Returns the current number of messages in the history
65 MessageCount() int
66
67 TotalUsage() ant.CumulativeUsage
68 OriginalBudget() ant.Budget
69
Earl Lee2e463fb2025-04-17 11:22:22 -070070 WorkingDir() string
71
72 // Diff returns a unified diff of changes made since the agent was instantiated.
73 // If commit is non-nil, it shows the diff for just that specific commit.
74 Diff(commit *string) (string, error)
75
76 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
77 InitialCommit() string
78
79 // Title returns the current title of the conversation.
80 Title() string
81
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000082 // BranchName returns the git branch name for the conversation.
83 BranchName() string
84
Earl Lee2e463fb2025-04-17 11:22:22 -070085 // OS returns the operating system of the client.
86 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000087
Philip Zeyligerc72fff52025-04-29 20:17:54 +000088 // SessionID returns the unique session identifier.
89 SessionID() string
90
Philip Zeyliger99a9a022025-04-27 15:15:25 +000091 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
92 OutstandingLLMCallCount() int
93
94 // OutstandingToolCalls returns the names of outstanding tool calls.
95 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +000096 OutsideOS() string
97 OutsideHostname() string
98 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +000099 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000100 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
101 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700102
103 // RestartConversation resets the conversation history
104 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
105 // SuggestReprompt suggests a re-prompt based on the current conversation.
106 SuggestReprompt(ctx context.Context) (string, error)
107 // IsInContainer returns true if the agent is running in a container
108 IsInContainer() bool
109 // FirstMessageIndex returns the index of the first message in the current conversation
110 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700111
112 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700113}
114
115type CodingAgentMessageType string
116
117const (
118 UserMessageType CodingAgentMessageType = "user"
119 AgentMessageType CodingAgentMessageType = "agent"
120 ErrorMessageType CodingAgentMessageType = "error"
121 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
122 ToolUseMessageType CodingAgentMessageType = "tool"
123 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
124 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
125
126 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
127)
128
129type AgentMessage struct {
130 Type CodingAgentMessageType `json:"type"`
131 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
132 EndOfTurn bool `json:"end_of_turn"`
133
134 Content string `json:"content"`
135 ToolName string `json:"tool_name,omitempty"`
136 ToolInput string `json:"input,omitempty"`
137 ToolResult string `json:"tool_result,omitempty"`
138 ToolError bool `json:"tool_error,omitempty"`
139 ToolCallId string `json:"tool_call_id,omitempty"`
140
141 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
142 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
143
Sean McCulloughd9f13372025-04-21 15:08:49 -0700144 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
145 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
146
Earl Lee2e463fb2025-04-17 11:22:22 -0700147 // Commits is a list of git commits for a commit message
148 Commits []*GitCommit `json:"commits,omitempty"`
149
150 Timestamp time.Time `json:"timestamp"`
151 ConversationID string `json:"conversation_id"`
152 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
153 Usage *ant.Usage `json:"usage,omitempty"`
154
155 // Message timing information
156 StartTime *time.Time `json:"start_time,omitempty"`
157 EndTime *time.Time `json:"end_time,omitempty"`
158 Elapsed *time.Duration `json:"elapsed,omitempty"`
159
160 // Turn duration - the time taken for a complete agent turn
161 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
162
163 Idx int `json:"idx"`
164}
165
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700166// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
167func (m *AgentMessage) SetConvo(convo *ant.Convo) {
168 if convo == nil {
169 m.ConversationID = ""
170 m.ParentConversationID = nil
171 return
172 }
173 m.ConversationID = convo.ID
174 if convo.Parent != nil {
175 m.ParentConversationID = &convo.Parent.ID
176 }
177}
178
Earl Lee2e463fb2025-04-17 11:22:22 -0700179// GitCommit represents a single git commit for a commit message
180type GitCommit struct {
181 Hash string `json:"hash"` // Full commit hash
182 Subject string `json:"subject"` // Commit subject line
183 Body string `json:"body"` // Full commit message body
184 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
185}
186
187// ToolCall represents a single tool call within an agent message
188type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700189 Name string `json:"name"`
190 Input string `json:"input"`
191 ToolCallId string `json:"tool_call_id"`
192 ResultMessage *AgentMessage `json:"result_message,omitempty"`
193 Args string `json:"args,omitempty"`
194 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700195}
196
197func (a *AgentMessage) Attr() slog.Attr {
198 var attrs []any = []any{
199 slog.String("type", string(a.Type)),
200 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700201 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700202 if a.EndOfTurn {
203 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
204 }
205 if a.Content != "" {
206 attrs = append(attrs, slog.String("content", a.Content))
207 }
208 if a.ToolName != "" {
209 attrs = append(attrs, slog.String("tool_name", a.ToolName))
210 }
211 if a.ToolInput != "" {
212 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
213 }
214 if a.Elapsed != nil {
215 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
216 }
217 if a.TurnDuration != nil {
218 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
219 }
220 if a.ToolResult != "" {
221 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
222 }
223 if a.ToolError {
224 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
225 }
226 if len(a.ToolCalls) > 0 {
227 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
228 for i, tc := range a.ToolCalls {
229 toolCallAttrs = append(toolCallAttrs, slog.Group(
230 fmt.Sprintf("tool_call_%d", i),
231 slog.String("name", tc.Name),
232 slog.String("input", tc.Input),
233 ))
234 }
235 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
236 }
237 if a.ConversationID != "" {
238 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
239 }
240 if a.ParentConversationID != nil {
241 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
242 }
243 if a.Usage != nil && !a.Usage.IsZero() {
244 attrs = append(attrs, a.Usage.Attr())
245 }
246 // TODO: timestamp, convo ids, idx?
247 return slog.Group("agent_message", attrs...)
248}
249
250func errorMessage(err error) AgentMessage {
251 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
252 if os.Getenv(("DEBUG")) == "1" {
253 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
254 }
255
256 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
257}
258
259func budgetMessage(err error) AgentMessage {
260 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
261}
262
263// ConvoInterface defines the interface for conversation interactions
264type ConvoInterface interface {
265 CumulativeUsage() ant.CumulativeUsage
266 ResetBudget(ant.Budget)
267 OverBudget() error
268 SendMessage(message ant.Message) (*ant.MessageResponse, error)
269 SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700270 GetID() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700271 ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
272 ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error)
273 CancelToolUse(toolUseID string, cause error) error
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700274 SubConvoWithHistory() *ant.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700275}
276
277type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700278 convo ConvoInterface
279 config AgentConfig // config for this agent
280 workingDir string
281 repoRoot string // workingDir may be a subdir of repoRoot
282 url string
283 firstMessageIndex int // index of the first message in the current conversation
284 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
285 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
286 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000287 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700288 ready chan struct{} // closed when the agent is initialized (only when under docker)
289 startedAt time.Time
290 originalBudget ant.Budget
291 title string
292 branchName string
293 codereview *claudetool.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700294 // State machine to track agent state
295 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000296 // Outside information
297 outsideHostname string
298 outsideOS string
299 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000300 // URL of the git remote 'origin' if it exists
301 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700302
303 // Time when the current turn started (reset at the beginning of InnerLoop)
304 startOfTurn time.Time
305
306 // Inbox - for messages from the user to the agent.
307 // sent on by UserMessage
308 // . e.g. when user types into the chat textarea
309 // read from by GatherMessages
310 inbox chan string
311
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000312 // protects cancelTurn
313 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700314 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000315 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700316
317 // protects following
318 mu sync.Mutex
319
320 // Stores all messages for this agent
321 history []AgentMessage
322
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700323 // Iterators add themselves here when they're ready to be notified of new messages.
324 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700325
326 // Track git commits we've already seen (by hash)
327 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000328
329 // Track outstanding LLM call IDs
330 outstandingLLMCalls map[string]struct{}
331
332 // Track outstanding tool calls by ID with their names
333 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700334}
335
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700336// NewIterator implements CodingAgent.
337func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
338 a.mu.Lock()
339 defer a.mu.Unlock()
340
341 return &MessageIteratorImpl{
342 agent: a,
343 ctx: ctx,
344 nextMessageIdx: nextMessageIdx,
345 ch: make(chan *AgentMessage, 100),
346 }
347}
348
349type MessageIteratorImpl struct {
350 agent *Agent
351 ctx context.Context
352 nextMessageIdx int
353 ch chan *AgentMessage
354 subscribed bool
355}
356
357func (m *MessageIteratorImpl) Close() {
358 m.agent.mu.Lock()
359 defer m.agent.mu.Unlock()
360 // Delete ourselves from the subscribers list
361 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
362 return x == m.ch
363 })
364 close(m.ch)
365}
366
367func (m *MessageIteratorImpl) Next() *AgentMessage {
368 // We avoid subscription at creation to let ourselves catch up to "current state"
369 // before subscribing.
370 if !m.subscribed {
371 m.agent.mu.Lock()
372 if m.nextMessageIdx < len(m.agent.history) {
373 msg := &m.agent.history[m.nextMessageIdx]
374 m.nextMessageIdx++
375 m.agent.mu.Unlock()
376 return msg
377 }
378 // The next message doesn't exist yet, so let's subscribe
379 m.agent.subscribers = append(m.agent.subscribers, m.ch)
380 m.subscribed = true
381 m.agent.mu.Unlock()
382 }
383
384 for {
385 select {
386 case <-m.ctx.Done():
387 m.agent.mu.Lock()
388 // Delete ourselves from the subscribers list
389 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
390 return x == m.ch
391 })
392 m.subscribed = false
393 m.agent.mu.Unlock()
394 return nil
395 case msg, ok := <-m.ch:
396 if !ok {
397 // Close may have been called
398 return nil
399 }
400 if msg.Idx == m.nextMessageIdx {
401 m.nextMessageIdx++
402 return msg
403 }
404 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
405 panic("out of order message")
406 }
407 }
408}
409
Sean McCulloughd9d45812025-04-30 16:53:41 -0700410// Assert that Agent satisfies the CodingAgent interface.
411var _ CodingAgent = &Agent{}
412
413// StateName implements CodingAgent.
414func (a *Agent) CurrentStateName() string {
415 if a.stateMachine == nil {
416 return ""
417 }
418 return a.stateMachine.currentState.String()
419}
420
Earl Lee2e463fb2025-04-17 11:22:22 -0700421func (a *Agent) URL() string { return a.url }
422
423// Title returns the current title of the conversation.
424// If no title has been set, returns an empty string.
425func (a *Agent) Title() string {
426 a.mu.Lock()
427 defer a.mu.Unlock()
428 return a.title
429}
430
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000431// BranchName returns the git branch name for the conversation.
432func (a *Agent) BranchName() string {
433 a.mu.Lock()
434 defer a.mu.Unlock()
435 return a.branchName
436}
437
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000438// OutstandingLLMCallCount returns the number of outstanding LLM calls.
439func (a *Agent) OutstandingLLMCallCount() int {
440 a.mu.Lock()
441 defer a.mu.Unlock()
442 return len(a.outstandingLLMCalls)
443}
444
445// OutstandingToolCalls returns the names of outstanding tool calls.
446func (a *Agent) OutstandingToolCalls() []string {
447 a.mu.Lock()
448 defer a.mu.Unlock()
449
450 tools := make([]string, 0, len(a.outstandingToolCalls))
451 for _, toolName := range a.outstandingToolCalls {
452 tools = append(tools, toolName)
453 }
454 return tools
455}
456
Earl Lee2e463fb2025-04-17 11:22:22 -0700457// OS returns the operating system of the client.
458func (a *Agent) OS() string {
459 return a.config.ClientGOOS
460}
461
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000462func (a *Agent) SessionID() string {
463 return a.config.SessionID
464}
465
Philip Zeyliger18532b22025-04-23 21:11:46 +0000466// OutsideOS returns the operating system of the outside system.
467func (a *Agent) OutsideOS() string {
468 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000469}
470
Philip Zeyliger18532b22025-04-23 21:11:46 +0000471// OutsideHostname returns the hostname of the outside system.
472func (a *Agent) OutsideHostname() string {
473 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000474}
475
Philip Zeyliger18532b22025-04-23 21:11:46 +0000476// OutsideWorkingDir returns the working directory on the outside system.
477func (a *Agent) OutsideWorkingDir() string {
478 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000479}
480
481// GitOrigin returns the URL of the git remote 'origin' if it exists.
482func (a *Agent) GitOrigin() string {
483 return a.gitOrigin
484}
485
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000486func (a *Agent) OpenBrowser(url string) {
487 if !a.IsInContainer() {
488 browser.Open(url)
489 return
490 }
491 // We're in Docker, need to send a request to the Git server
492 // to signal that the outer process should open the browser.
493 httpc := &http.Client{Timeout: 5 * time.Second}
494 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", strings.NewReader(url))
495 if err != nil {
496 slog.Debug("browser launch request connection failed", "err", err, "url", url)
497 return
498 }
499 defer resp.Body.Close()
500 if resp.StatusCode == http.StatusOK {
501 return
502 }
503 body, _ := io.ReadAll(resp.Body)
504 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
505}
506
Sean McCullough96b60dd2025-04-30 09:49:10 -0700507// CurrentState returns the current state of the agent's state machine.
508func (a *Agent) CurrentState() State {
509 return a.stateMachine.CurrentState()
510}
511
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700512func (a *Agent) IsInContainer() bool {
513 return a.config.InDocker
514}
515
516func (a *Agent) FirstMessageIndex() int {
517 a.mu.Lock()
518 defer a.mu.Unlock()
519 return a.firstMessageIndex
520}
521
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700522// SetTitleBranch sets the title and branch name of the conversation.
523func (a *Agent) SetTitleBranch(title, branchName string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700524 a.mu.Lock()
525 defer a.mu.Unlock()
526 a.title = title
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700527 a.branchName = branchName
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700528
529 // TODO: We could potentially notify listeners of a state change, but,
530 // realistically, a new message will be sent for the tool result as well.
Earl Lee2e463fb2025-04-17 11:22:22 -0700531}
532
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000533// OnToolCall implements ant.Listener and tracks the start of a tool call.
534func (a *Agent) OnToolCall(ctx context.Context, convo *ant.Convo, id string, toolName string, toolInput json.RawMessage, content ant.Content) {
535 // Track the tool call
536 a.mu.Lock()
537 a.outstandingToolCalls[id] = toolName
538 a.mu.Unlock()
539}
540
Earl Lee2e463fb2025-04-17 11:22:22 -0700541// OnToolResult implements ant.Listener.
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000542func (a *Agent) OnToolResult(ctx context.Context, convo *ant.Convo, toolID string, toolName string, toolInput json.RawMessage, content ant.Content, result *string, err error) {
543 // Remove the tool call from outstanding calls
544 a.mu.Lock()
545 delete(a.outstandingToolCalls, toolID)
546 a.mu.Unlock()
547
Earl Lee2e463fb2025-04-17 11:22:22 -0700548 m := AgentMessage{
549 Type: ToolUseMessageType,
550 Content: content.Text,
551 ToolResult: content.ToolResult,
552 ToolError: content.ToolError,
553 ToolName: toolName,
554 ToolInput: string(toolInput),
555 ToolCallId: content.ToolUseID,
556 StartTime: content.StartTime,
557 EndTime: content.EndTime,
558 }
559
560 // Calculate the elapsed time if both start and end times are set
561 if content.StartTime != nil && content.EndTime != nil {
562 elapsed := content.EndTime.Sub(*content.StartTime)
563 m.Elapsed = &elapsed
564 }
565
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700566 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700567 a.pushToOutbox(ctx, m)
568}
569
570// OnRequest implements ant.Listener.
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000571func (a *Agent) OnRequest(ctx context.Context, convo *ant.Convo, id string, msg *ant.Message) {
572 a.mu.Lock()
573 defer a.mu.Unlock()
574 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700575 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
576}
577
578// OnResponse implements ant.Listener. Responses contain messages from the LLM
579// that need to be displayed (as well as tool calls that we send along when
580// they're done). (It would be reasonable to also mention tool calls when they're
581// started, but we don't do that yet.)
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000582func (a *Agent) OnResponse(ctx context.Context, convo *ant.Convo, id string, resp *ant.MessageResponse) {
583 // Remove the LLM call from outstanding calls
584 a.mu.Lock()
585 delete(a.outstandingLLMCalls, id)
586 a.mu.Unlock()
587
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700588 if resp == nil {
589 // LLM API call failed
590 m := AgentMessage{
591 Type: ErrorMessageType,
592 Content: "API call failed, type 'continue' to try again",
593 }
594 m.SetConvo(convo)
595 a.pushToOutbox(ctx, m)
596 return
597 }
598
Earl Lee2e463fb2025-04-17 11:22:22 -0700599 endOfTurn := false
600 if resp.StopReason != ant.StopReasonToolUse {
601 endOfTurn = true
602 }
603 m := AgentMessage{
604 Type: AgentMessageType,
605 Content: collectTextContent(resp),
606 EndOfTurn: endOfTurn,
607 Usage: &resp.Usage,
608 StartTime: resp.StartTime,
609 EndTime: resp.EndTime,
610 }
611
612 // Extract any tool calls from the response
613 if resp.StopReason == ant.StopReasonToolUse {
614 var toolCalls []ToolCall
615 for _, part := range resp.Content {
Josh Bleecher Snydera3dcd862025-04-30 19:47:16 +0000616 if part.Type == ant.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700617 toolCalls = append(toolCalls, ToolCall{
618 Name: part.ToolName,
619 Input: string(part.ToolInput),
620 ToolCallId: part.ID,
621 })
622 }
623 }
624 m.ToolCalls = toolCalls
625 }
626
627 // Calculate the elapsed time if both start and end times are set
628 if resp.StartTime != nil && resp.EndTime != nil {
629 elapsed := resp.EndTime.Sub(*resp.StartTime)
630 m.Elapsed = &elapsed
631 }
632
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700633 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700634 a.pushToOutbox(ctx, m)
635}
636
637// WorkingDir implements CodingAgent.
638func (a *Agent) WorkingDir() string {
639 return a.workingDir
640}
641
642// MessageCount implements CodingAgent.
643func (a *Agent) MessageCount() int {
644 a.mu.Lock()
645 defer a.mu.Unlock()
646 return len(a.history)
647}
648
649// Messages implements CodingAgent.
650func (a *Agent) Messages(start int, end int) []AgentMessage {
651 a.mu.Lock()
652 defer a.mu.Unlock()
653 return slices.Clone(a.history[start:end])
654}
655
656func (a *Agent) OriginalBudget() ant.Budget {
657 return a.originalBudget
658}
659
660// AgentConfig contains configuration for creating a new Agent.
661type AgentConfig struct {
662 Context context.Context
663 AntURL string
664 APIKey string
665 HTTPC *http.Client
666 Budget ant.Budget
667 GitUsername string
668 GitEmail string
669 SessionID string
670 ClientGOOS string
671 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700672 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700673 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000674 // Outside information
675 OutsideHostname string
676 OutsideOS string
677 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700678}
679
680// NewAgent creates a new Agent.
681// It is not usable until Init() is called.
682func NewAgent(config AgentConfig) *Agent {
683 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000684 config: config,
685 ready: make(chan struct{}),
686 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700687 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000688 startedAt: time.Now(),
689 originalBudget: config.Budget,
690 seenCommits: make(map[string]bool),
691 outsideHostname: config.OutsideHostname,
692 outsideOS: config.OutsideOS,
693 outsideWorkingDir: config.OutsideWorkingDir,
694 outstandingLLMCalls: make(map[string]struct{}),
695 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700696 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700697 }
698 return agent
699}
700
701type AgentInit struct {
702 WorkingDir string
703 NoGit bool // only for testing
704
705 InDocker bool
706 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000707 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700708 GitRemoteAddr string
709 HostAddr string
710}
711
712func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700713 if a.convo != nil {
714 return fmt.Errorf("Agent.Init: already initialized")
715 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700716 ctx := a.config.Context
717 if ini.InDocker {
718 cmd := exec.CommandContext(ctx, "git", "stash")
719 cmd.Dir = ini.WorkingDir
720 if out, err := cmd.CombinedOutput(); err != nil {
721 return fmt.Errorf("git stash: %s: %v", out, err)
722 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700723 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
724 cmd.Dir = ini.WorkingDir
725 if out, err := cmd.CombinedOutput(); err != nil {
726 return fmt.Errorf("git remote add: %s: %v", out, err)
727 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000728 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700729 cmd.Dir = ini.WorkingDir
730 if out, err := cmd.CombinedOutput(); err != nil {
731 return fmt.Errorf("git fetch: %s: %w", out, err)
732 }
733 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
734 cmd.Dir = ini.WorkingDir
735 if out, err := cmd.CombinedOutput(); err != nil {
736 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
737 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700738 a.lastHEAD = ini.Commit
739 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000740 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700741 a.initialCommit = ini.Commit
742 if ini.HostAddr != "" {
743 a.url = "http://" + ini.HostAddr
744 }
745 }
746 a.workingDir = ini.WorkingDir
747
748 if !ini.NoGit {
749 repoRoot, err := repoRoot(ctx, a.workingDir)
750 if err != nil {
751 return fmt.Errorf("repoRoot: %w", err)
752 }
753 a.repoRoot = repoRoot
754
755 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
756 if err != nil {
757 return fmt.Errorf("resolveRef: %w", err)
758 }
759 a.initialCommit = commitHash
760
761 codereview, err := claudetool.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit)
762 if err != nil {
763 return fmt.Errorf("Agent.Init: claudetool.NewCodeReviewer: %w", err)
764 }
765 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000766
767 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700768 }
769 a.lastHEAD = a.initialCommit
770 a.convo = a.initConvo()
771 close(a.ready)
772 return nil
773}
774
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700775//go:embed agent_system_prompt.txt
776var agentSystemPrompt string
777
Earl Lee2e463fb2025-04-17 11:22:22 -0700778// initConvo initializes the conversation.
779// It must not be called until all agent fields are initialized,
780// particularly workingDir and git.
781func (a *Agent) initConvo() *ant.Convo {
782 ctx := a.config.Context
783 convo := ant.NewConvo(ctx, a.config.APIKey)
784 if a.config.HTTPC != nil {
785 convo.HTTPC = a.config.HTTPC
786 }
787 if a.config.AntURL != "" {
788 convo.URL = a.config.AntURL
789 }
790 convo.PromptCaching = true
791 convo.Budget = a.config.Budget
792
793 var editPrompt string
794 if a.config.UseAnthropicEdit {
795 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."
796 } else {
797 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
798 }
799
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700800 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 -0700801
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000802 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
803 bashPermissionCheck := func(command string) error {
804 // Check if branch name is set
805 a.mu.Lock()
806 branchSet := a.branchName != ""
807 a.mu.Unlock()
808
809 // If branch is set, all commands are allowed
810 if branchSet {
811 return nil
812 }
813
814 // If branch is not set, check if this is a git commit command
815 willCommit, err := bashkit.WillRunGitCommit(command)
816 if err != nil {
817 // If there's an error checking, we should allow the command to proceed
818 return nil
819 }
820
821 // If it's a git commit and branch is not set, return an error
822 if willCommit {
823 return fmt.Errorf("you must use the title tool before making git commits")
824 }
825
826 return nil
827 }
828
829 // Create a custom bash tool with the permission check
830 bashTool := claudetool.NewBashTool(bashPermissionCheck)
831
Earl Lee2e463fb2025-04-17 11:22:22 -0700832 // Register all tools with the conversation
833 // When adding, removing, or modifying tools here, double-check that the termui tool display
834 // template in termui/termui.go has pretty-printing support for all tools.
835 convo.Tools = []*ant.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000836 bashTool, claudetool.Keyword,
Earl Lee2e463fb2025-04-17 11:22:22 -0700837 claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
838 a.codereview.Tool(),
839 }
840 if a.config.UseAnthropicEdit {
841 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
842 } else {
843 convo.Tools = append(convo.Tools, claudetool.Patch)
844 }
845 convo.Listener = a
846 return convo
847}
848
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000849// branchExists reports whether branchName exists, either locally or in well-known remotes.
850func branchExists(dir, branchName string) bool {
851 refs := []string{
852 "refs/heads/",
853 "refs/remotes/origin/",
854 "refs/remotes/sketch-host/",
855 }
856 for _, ref := range refs {
857 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
858 cmd.Dir = dir
859 if cmd.Run() == nil { // exit code 0 means branch exists
860 return true
861 }
862 }
863 return false
864}
865
Earl Lee2e463fb2025-04-17 11:22:22 -0700866func (a *Agent) titleTool() *ant.Tool {
Earl Lee2e463fb2025-04-17 11:22:22 -0700867 title := &ant.Tool{
868 Name: "title",
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700869 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 -0700870 InputSchema: json.RawMessage(`{
871 "type": "object",
872 "properties": {
873 "title": {
874 "type": "string",
Josh Bleecher Snyder250348e2025-04-30 10:31:28 -0700875 "description": "A concise title summarizing what this conversation is about, imperative tense preferred"
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700876 },
877 "branch_name": {
878 "type": "string",
879 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700880 }
881 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700882 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700883}`),
884 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
885 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700886 Title string `json:"title"`
887 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700888 }
889 if err := json.Unmarshal(input, &params); err != nil {
890 return "", err
891 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700892 // It's unfortunate to not allow title changes,
893 // but it avoids having multiple branches.
894 t := a.Title()
895 if t != "" {
896 return "", fmt.Errorf("title already set to: %s", t)
897 }
898
899 if params.BranchName == "" {
900 return "", fmt.Errorf("branch_name parameter cannot be empty")
901 }
902 if params.Title == "" {
903 return "", fmt.Errorf("title parameter cannot be empty")
904 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -0700905 if params.BranchName != cleanBranchName(params.BranchName) {
906 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
907 }
908 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000909 if branchExists(a.workingDir, branchName) {
910 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
911 }
912
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700913 a.SetTitleBranch(params.Title, branchName)
914
915 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
916 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700917 },
918 }
919 return title
920}
921
922func (a *Agent) Ready() <-chan struct{} {
923 return a.ready
924}
925
926func (a *Agent) UserMessage(ctx context.Context, msg string) {
927 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
928 a.inbox <- msg
929}
930
Earl Lee2e463fb2025-04-17 11:22:22 -0700931func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
932 return a.convo.CancelToolUse(toolUseID, cause)
933}
934
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000935func (a *Agent) CancelTurn(cause error) {
936 a.cancelTurnMu.Lock()
937 defer a.cancelTurnMu.Unlock()
938 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -0700939 // Force state transition to cancelled state
940 ctx := a.config.Context
941 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000942 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -0700943 }
944}
945
946func (a *Agent) Loop(ctxOuter context.Context) {
947 for {
948 select {
949 case <-ctxOuter.Done():
950 return
951 default:
952 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000953 a.cancelTurnMu.Lock()
954 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +0000955 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000956 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -0700957 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000958 a.cancelTurn = cancel
959 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +0000960 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
961 if err != nil {
962 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
963 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700964 cancel(nil)
965 }
966 }
967}
968
969func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
970 if m.Timestamp.IsZero() {
971 m.Timestamp = time.Now()
972 }
973
974 // If this is an end-of-turn message, calculate the turn duration and add it to the message
975 if m.EndOfTurn && m.Type == AgentMessageType {
976 turnDuration := time.Since(a.startOfTurn)
977 m.TurnDuration = &turnDuration
978 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
979 }
980
Earl Lee2e463fb2025-04-17 11:22:22 -0700981 a.mu.Lock()
982 defer a.mu.Unlock()
983 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700984 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -0700985 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -0700986
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700987 // Notify all subscribers
988 for _, ch := range a.subscribers {
989 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -0700990 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700991}
992
993func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]ant.Content, error) {
994 var m []ant.Content
995 if block {
996 select {
997 case <-ctx.Done():
998 return m, ctx.Err()
999 case msg := <-a.inbox:
Josh Bleecher Snydera3dcd862025-04-30 19:47:16 +00001000 m = append(m, ant.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001001 }
1002 }
1003 for {
1004 select {
1005 case msg := <-a.inbox:
Josh Bleecher Snydera3dcd862025-04-30 19:47:16 +00001006 m = append(m, ant.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001007 default:
1008 return m, nil
1009 }
1010 }
1011}
1012
Sean McCullough885a16a2025-04-30 02:49:25 +00001013// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001014func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001015 // Reset the start of turn time
1016 a.startOfTurn = time.Now()
1017
Sean McCullough96b60dd2025-04-30 09:49:10 -07001018 // Transition to waiting for user input state
1019 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1020
Sean McCullough885a16a2025-04-30 02:49:25 +00001021 // Process initial user message
1022 initialResp, err := a.processUserMessage(ctx)
1023 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001024 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001025 return err
1026 }
1027
1028 // Handle edge case where both initialResp and err are nil
1029 if initialResp == nil {
1030 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001031 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1032
Sean McCullough9f4b8082025-04-30 17:34:07 +00001033 a.pushToOutbox(ctx, errorMessage(err))
1034 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001035 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001036
Earl Lee2e463fb2025-04-17 11:22:22 -07001037 // We do this as we go, but let's also do it at the end of the turn
1038 defer func() {
1039 if _, err := a.handleGitCommits(ctx); err != nil {
1040 // Just log the error, don't stop execution
1041 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1042 }
1043 }()
1044
Sean McCullougha1e0e492025-05-01 10:51:08 -07001045 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001046 resp := initialResp
1047 for {
1048 // Check if we are over budget
1049 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001050 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001051 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001052 }
1053
1054 // If the model is not requesting to use a tool, we're done
1055 if resp.StopReason != ant.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001056 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001057 break
1058 }
1059
Sean McCullough96b60dd2025-04-30 09:49:10 -07001060 // Transition to tool use requested state
1061 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1062
Sean McCullough885a16a2025-04-30 02:49:25 +00001063 // Handle tool execution
1064 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1065 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001066 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001067 }
1068
Sean McCullougha1e0e492025-05-01 10:51:08 -07001069 if toolResp == nil {
1070 return fmt.Errorf("cannot continue conversation with a nil tool response")
1071 }
1072
Sean McCullough885a16a2025-04-30 02:49:25 +00001073 // Set the response for the next iteration
1074 resp = toolResp
1075 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001076
1077 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001078}
1079
1080// processUserMessage waits for user messages and sends them to the model
1081func (a *Agent) processUserMessage(ctx context.Context) (*ant.MessageResponse, error) {
1082 // Wait for at least one message from the user
1083 msgs, err := a.GatherMessages(ctx, true)
1084 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001085 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001086 return nil, err
1087 }
1088
Earl Lee2e463fb2025-04-17 11:22:22 -07001089 userMessage := ant.Message{
Josh Bleecher Snydera3dcd862025-04-30 19:47:16 +00001090 Role: ant.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001091 Content: msgs,
1092 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001093
Sean McCullough96b60dd2025-04-30 09:49:10 -07001094 // Transition to sending to LLM state
1095 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1096
Sean McCullough885a16a2025-04-30 02:49:25 +00001097 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001098 resp, err := a.convo.SendMessage(userMessage)
1099 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001100 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001101 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001102 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001103 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001104
Sean McCullough96b60dd2025-04-30 09:49:10 -07001105 // Transition to processing LLM response state
1106 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1107
Sean McCullough885a16a2025-04-30 02:49:25 +00001108 return resp, nil
1109}
1110
1111// handleToolExecution processes a tool use request from the model
1112func (a *Agent) handleToolExecution(ctx context.Context, resp *ant.MessageResponse) (bool, *ant.MessageResponse) {
1113 var results []ant.Content
1114 cancelled := false
1115
Sean McCullough96b60dd2025-04-30 09:49:10 -07001116 // Transition to checking for cancellation state
1117 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1118
Sean McCullough885a16a2025-04-30 02:49:25 +00001119 // Check if the operation was cancelled by the user
1120 select {
1121 case <-ctx.Done():
1122 // Don't actually run any of the tools, but rather build a response
1123 // for each tool_use message letting the LLM know that user canceled it.
1124 var err error
1125 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001126 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001127 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001128 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001129 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001130 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001131 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001132 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001133 // Transition to running tool state
1134 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1135
Sean McCullough885a16a2025-04-30 02:49:25 +00001136 // Add working directory to context for tool execution
1137 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1138
1139 // Execute the tools
1140 var err error
1141 results, err = a.convo.ToolResultContents(ctx, resp)
1142 if ctx.Err() != nil { // e.g. the user canceled the operation
1143 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001144 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001145 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001146 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001147 a.pushToOutbox(ctx, errorMessage(err))
1148 }
1149 }
1150
1151 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001152 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001153 autoqualityMessages := a.processGitChanges(ctx)
1154
1155 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001156 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001157 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001158 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001159 return false, nil
1160 }
1161
1162 // Continue the conversation with tool results and any user messages
1163 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1164}
1165
1166// processGitChanges checks for new git commits and runs autoformatters if needed
1167func (a *Agent) processGitChanges(ctx context.Context) []string {
1168 // Check for git commits after tool execution
1169 newCommits, err := a.handleGitCommits(ctx)
1170 if err != nil {
1171 // Just log the error, don't stop execution
1172 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1173 return nil
1174 }
1175
1176 // Run autoformatters if there was exactly one new commit
1177 var autoqualityMessages []string
1178 if len(newCommits) == 1 {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001179 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running autoformatters on new commit")
Sean McCullough885a16a2025-04-30 02:49:25 +00001180 formatted := a.codereview.Autoformat(ctx)
1181 if len(formatted) > 0 {
1182 msg := fmt.Sprintf(`
Earl Lee2e463fb2025-04-17 11:22:22 -07001183I ran autoformatters and they updated these files:
1184
1185%s
1186
1187Please amend your latest git commit with these changes and then continue with what you were doing.`,
Sean McCullough885a16a2025-04-30 02:49:25 +00001188 strings.Join(formatted, "\n"),
1189 )[1:]
1190 a.pushToOutbox(ctx, AgentMessage{
1191 Type: AutoMessageType,
1192 Content: msg,
1193 Timestamp: time.Now(),
1194 })
1195 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001196 }
1197 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001198
1199 return autoqualityMessages
1200}
1201
1202// continueTurnWithToolResults continues the conversation with tool results
1203func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []ant.Content, autoqualityMessages []string, cancelled bool) (bool, *ant.MessageResponse) {
1204 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001205 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001206 msgs, err := a.GatherMessages(ctx, false)
1207 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001208 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001209 return false, nil
1210 }
1211
1212 // Inject any auto-generated messages from quality checks
1213 for _, msg := range autoqualityMessages {
Josh Bleecher Snydera3dcd862025-04-30 19:47:16 +00001214 msgs = append(msgs, ant.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001215 }
1216
1217 // Handle cancellation by appending a message about it
1218 if cancelled {
Josh Bleecher Snydera3dcd862025-04-30 19:47:16 +00001219 msgs = append(msgs, ant.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001220 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001221 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001222 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1223 } else if err := a.convo.OverBudget(); err != nil {
1224 // Handle budget issues by appending a message about it
1225 budgetMsg := "We've exceeded our budget. Please ask the user to confirm before continuing by ending the turn."
Josh Bleecher Snydera3dcd862025-04-30 19:47:16 +00001226 msgs = append(msgs, ant.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001227 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1228 }
1229
1230 // Combine tool results with user messages
1231 results = append(results, msgs...)
1232
1233 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001234 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Sean McCullough885a16a2025-04-30 02:49:25 +00001235 resp, err := a.convo.SendMessage(ant.Message{
Josh Bleecher Snydera3dcd862025-04-30 19:47:16 +00001236 Role: ant.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001237 Content: results,
1238 })
1239 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001240 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001241 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1242 return true, nil // Return true to continue the conversation, but with no response
1243 }
1244
Sean McCullough96b60dd2025-04-30 09:49:10 -07001245 // Transition back to processing LLM response
1246 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1247
Sean McCullough885a16a2025-04-30 02:49:25 +00001248 if cancelled {
1249 return false, nil
1250 }
1251
1252 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001253}
1254
1255func (a *Agent) overBudget(ctx context.Context) error {
1256 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001257 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001258 m := budgetMessage(err)
1259 m.Content = m.Content + "\n\nBudget reset."
1260 a.pushToOutbox(ctx, budgetMessage(err))
1261 a.convo.ResetBudget(a.originalBudget)
1262 return err
1263 }
1264 return nil
1265}
1266
1267func collectTextContent(msg *ant.MessageResponse) string {
1268 // Collect all text content
1269 var allText strings.Builder
1270 for _, content := range msg.Content {
Josh Bleecher Snydera3dcd862025-04-30 19:47:16 +00001271 if content.Type == ant.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001272 if allText.Len() > 0 {
1273 allText.WriteString("\n\n")
1274 }
1275 allText.WriteString(content.Text)
1276 }
1277 }
1278 return allText.String()
1279}
1280
1281func (a *Agent) TotalUsage() ant.CumulativeUsage {
1282 a.mu.Lock()
1283 defer a.mu.Unlock()
1284 return a.convo.CumulativeUsage()
1285}
1286
Earl Lee2e463fb2025-04-17 11:22:22 -07001287// Diff returns a unified diff of changes made since the agent was instantiated.
1288func (a *Agent) Diff(commit *string) (string, error) {
1289 if a.initialCommit == "" {
1290 return "", fmt.Errorf("no initial commit reference available")
1291 }
1292
1293 // Find the repository root
1294 ctx := context.Background()
1295
1296 // If a specific commit hash is provided, show just that commit's changes
1297 if commit != nil && *commit != "" {
1298 // Validate that the commit looks like a valid git SHA
1299 if !isValidGitSHA(*commit) {
1300 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1301 }
1302
1303 // Get the diff for just this commit
1304 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1305 cmd.Dir = a.repoRoot
1306 output, err := cmd.CombinedOutput()
1307 if err != nil {
1308 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1309 }
1310 return string(output), nil
1311 }
1312
1313 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1314 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1315 cmd.Dir = a.repoRoot
1316 output, err := cmd.CombinedOutput()
1317 if err != nil {
1318 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1319 }
1320
1321 return string(output), nil
1322}
1323
1324// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1325func (a *Agent) InitialCommit() string {
1326 return a.initialCommit
1327}
1328
1329// handleGitCommits() highlights new commits to the user. When running
1330// under docker, new HEADs are pushed to a branch according to the title.
1331func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1332 if a.repoRoot == "" {
1333 return nil, nil
1334 }
1335
1336 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1337 if err != nil {
1338 return nil, err
1339 }
1340 if head == a.lastHEAD {
1341 return nil, nil // nothing to do
1342 }
1343 defer func() {
1344 a.lastHEAD = head
1345 }()
1346
1347 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1348 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1349 // to the last 100 commits.
1350 var commits []*GitCommit
1351
1352 // Get commits since the initial commit
1353 // Format: <hash>\0<subject>\0<body>\0
1354 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1355 // Limit to 100 commits to avoid overwhelming the user
1356 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1357 cmd.Dir = a.repoRoot
1358 output, err := cmd.Output()
1359 if err != nil {
1360 return nil, fmt.Errorf("failed to get git log: %w", err)
1361 }
1362
1363 // Parse git log output and filter out already seen commits
1364 parsedCommits := parseGitLog(string(output))
1365
1366 var headCommit *GitCommit
1367
1368 // Filter out commits we've already seen
1369 for _, commit := range parsedCommits {
1370 if commit.Hash == head {
1371 headCommit = &commit
1372 }
1373
1374 // Skip if we've seen this commit before. If our head has changed, always include that.
1375 if a.seenCommits[commit.Hash] && commit.Hash != head {
1376 continue
1377 }
1378
1379 // Mark this commit as seen
1380 a.seenCommits[commit.Hash] = true
1381
1382 // Add to our list of new commits
1383 commits = append(commits, &commit)
1384 }
1385
1386 if a.gitRemoteAddr != "" {
1387 if headCommit == nil {
1388 // I think this can only happen if we have a bug or if there's a race.
1389 headCommit = &GitCommit{}
1390 headCommit.Hash = head
1391 headCommit.Subject = "unknown"
1392 commits = append(commits, headCommit)
1393 }
1394
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001395 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001396
1397 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1398 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1399 // then use push with lease to replace.
1400 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1401 cmd.Dir = a.workingDir
1402 if out, err := cmd.CombinedOutput(); err != nil {
1403 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1404 } else {
1405 headCommit.PushedBranch = branch
1406 }
1407 }
1408
1409 // If we found new commits, create a message
1410 if len(commits) > 0 {
1411 msg := AgentMessage{
1412 Type: CommitMessageType,
1413 Timestamp: time.Now(),
1414 Commits: commits,
1415 }
1416 a.pushToOutbox(ctx, msg)
1417 }
1418 return commits, nil
1419}
1420
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001421func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001422 return strings.Map(func(r rune) rune {
1423 // lowercase
1424 if r >= 'A' && r <= 'Z' {
1425 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001426 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001427 // replace spaces with dashes
1428 if r == ' ' {
1429 return '-'
1430 }
1431 // allow alphanumerics and dashes
1432 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1433 return r
1434 }
1435 return -1
1436 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001437}
1438
1439// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1440// and returns an array of GitCommit structs.
1441func parseGitLog(output string) []GitCommit {
1442 var commits []GitCommit
1443
1444 // No output means no commits
1445 if len(output) == 0 {
1446 return commits
1447 }
1448
1449 // Split by NULL byte
1450 parts := strings.Split(output, "\x00")
1451
1452 // Process in triplets (hash, subject, body)
1453 for i := 0; i < len(parts); i++ {
1454 // Skip empty parts
1455 if parts[i] == "" {
1456 continue
1457 }
1458
1459 // This should be a hash
1460 hash := strings.TrimSpace(parts[i])
1461
1462 // Make sure we have at least a subject part available
1463 if i+1 >= len(parts) {
1464 break // No more parts available
1465 }
1466
1467 // Get the subject
1468 subject := strings.TrimSpace(parts[i+1])
1469
1470 // Get the body if available
1471 body := ""
1472 if i+2 < len(parts) {
1473 body = strings.TrimSpace(parts[i+2])
1474 }
1475
1476 // Skip to the next triplet
1477 i += 2
1478
1479 commits = append(commits, GitCommit{
1480 Hash: hash,
1481 Subject: subject,
1482 Body: body,
1483 })
1484 }
1485
1486 return commits
1487}
1488
1489func repoRoot(ctx context.Context, dir string) (string, error) {
1490 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1491 stderr := new(strings.Builder)
1492 cmd.Stderr = stderr
1493 cmd.Dir = dir
1494 out, err := cmd.Output()
1495 if err != nil {
1496 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1497 }
1498 return strings.TrimSpace(string(out)), nil
1499}
1500
1501func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1502 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1503 stderr := new(strings.Builder)
1504 cmd.Stderr = stderr
1505 cmd.Dir = dir
1506 out, err := cmd.Output()
1507 if err != nil {
1508 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1509 }
1510 // TODO: validate that out is valid hex
1511 return strings.TrimSpace(string(out)), nil
1512}
1513
1514// isValidGitSHA validates if a string looks like a valid git SHA hash.
1515// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1516func isValidGitSHA(sha string) bool {
1517 // Git SHA must be a hexadecimal string with at least 4 characters
1518 if len(sha) < 4 || len(sha) > 40 {
1519 return false
1520 }
1521
1522 // Check if the string only contains hexadecimal characters
1523 for _, char := range sha {
1524 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1525 return false
1526 }
1527 }
1528
1529 return true
1530}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001531
1532// getGitOrigin returns the URL of the git remote 'origin' if it exists
1533func getGitOrigin(ctx context.Context, dir string) string {
1534 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1535 cmd.Dir = dir
1536 stderr := new(strings.Builder)
1537 cmd.Stderr = stderr
1538 out, err := cmd.Output()
1539 if err != nil {
1540 return ""
1541 }
1542 return strings.TrimSpace(string(out))
1543}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001544
1545func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1546 cmd := exec.CommandContext(ctx, "git", "stash")
1547 cmd.Dir = workingDir
1548 if out, err := cmd.CombinedOutput(); err != nil {
1549 return fmt.Errorf("git stash: %s: %v", out, err)
1550 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001551 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001552 cmd.Dir = workingDir
1553 if out, err := cmd.CombinedOutput(); err != nil {
1554 return fmt.Errorf("git fetch: %s: %w", out, err)
1555 }
1556 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1557 cmd.Dir = workingDir
1558 if out, err := cmd.CombinedOutput(); err != nil {
1559 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1560 }
1561 a.lastHEAD = revision
1562 a.initialCommit = revision
1563 return nil
1564}
1565
1566func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1567 a.mu.Lock()
1568 a.title = ""
1569 a.firstMessageIndex = len(a.history)
1570 a.convo = a.initConvo()
1571 gitReset := func() error {
1572 if a.config.InDocker && rev != "" {
1573 err := a.initGitRevision(ctx, a.workingDir, rev)
1574 if err != nil {
1575 return err
1576 }
1577 } else if !a.config.InDocker && rev != "" {
1578 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1579 }
1580 return nil
1581 }
1582 err := gitReset()
1583 a.mu.Unlock()
1584 if err != nil {
1585 a.pushToOutbox(a.config.Context, errorMessage(err))
1586 }
1587
1588 a.pushToOutbox(a.config.Context, AgentMessage{
1589 Type: AgentMessageType, Content: "Conversation restarted.",
1590 })
1591 if initialPrompt != "" {
1592 a.UserMessage(ctx, initialPrompt)
1593 }
1594 return nil
1595}
1596
1597func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1598 msg := `The user has requested a suggestion for a re-prompt.
1599
1600 Given the current conversation thus far, suggest a re-prompt that would
1601 capture the instructions and feedback so far, as well as any
1602 research or other information that would be helpful in implementing
1603 the task.
1604
1605 Reply with ONLY the reprompt text.
1606 `
Josh Bleecher Snydera3dcd862025-04-30 19:47:16 +00001607 userMessage := ant.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001608 // By doing this in a subconversation, the agent doesn't call tools (because
1609 // there aren't any), and there's not a concurrency risk with on-going other
1610 // outstanding conversations.
1611 convo := a.convo.SubConvoWithHistory()
1612 resp, err := convo.SendMessage(userMessage)
1613 if err != nil {
1614 a.pushToOutbox(ctx, errorMessage(err))
1615 return "", err
1616 }
1617 textContent := collectTextContent(resp)
1618 return textContent, nil
1619}