| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1 | package loop |
| 2 | |
| 3 | import ( |
| Josh Bleecher Snyder | a9b3822 | 2025-04-29 18:05:06 -0700 | [diff] [blame] | 4 | "cmp" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 5 | "context" |
| Josh Bleecher Snyder | dbe0230 | 2025-04-29 16:44:23 -0700 | [diff] [blame] | 6 | _ "embed" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 7 | "encoding/json" |
| 8 | "fmt" |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 9 | "io" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 10 | "log/slog" |
| 11 | "net/http" |
| 12 | "os" |
| 13 | "os/exec" |
| 14 | "runtime/debug" |
| 15 | "slices" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "time" |
| 19 | |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 20 | "sketch.dev/browser" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 21 | "sketch.dev/claudetool" |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 22 | "sketch.dev/claudetool/bashkit" |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 23 | "sketch.dev/llm" |
| 24 | "sketch.dev/llm/conversation" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 25 | ) |
| 26 | |
| 27 | const ( |
| 28 | userCancelMessage = "user requested agent to stop handling responses" |
| 29 | ) |
| 30 | |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 31 | type 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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 38 | type 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 Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 51 | // 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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 54 | |
| 55 | // Loop begins the agent loop returns only when ctx is cancelled. |
| 56 | Loop(ctx context.Context) |
| 57 | |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 58 | CancelTurn(cause error) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 59 | |
| 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 Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 68 | TotalUsage() conversation.CumulativeUsage |
| 69 | OriginalBudget() conversation.Budget |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 70 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 71 | 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 Snyder | 47b1936 | 2025-04-30 01:34:14 +0000 | [diff] [blame] | 83 | // BranchName returns the git branch name for the conversation. |
| 84 | BranchName() string |
| 85 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 86 | // OS returns the operating system of the client. |
| 87 | OS() string |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 88 | |
| Philip Zeyliger | c72fff5 | 2025-04-29 20:17:54 +0000 | [diff] [blame] | 89 | // SessionID returns the unique session identifier. |
| 90 | SessionID() string |
| 91 | |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 92 | // 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 Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 97 | OutsideOS() string |
| 98 | OutsideHostname() string |
| 99 | OutsideWorkingDir() string |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 100 | GitOrigin() string |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 101 | // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch. |
| 102 | OpenBrowser(url string) |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 103 | |
| 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 McCullough | d9d4581 | 2025-04-30 16:53:41 -0700 | [diff] [blame] | 112 | |
| 113 | CurrentStateName() string |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 114 | } |
| 115 | |
| 116 | type CodingAgentMessageType string |
| 117 | |
| 118 | const ( |
| 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 | |
| 130 | type 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 McCullough | d9f1337 | 2025-04-21 15:08:49 -0700 | [diff] [blame] | 145 | // 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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 148 | // 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 Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 154 | Usage *llm.Usage `json:"usage,omitempty"` |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 155 | |
| 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 Snyder | 50a1d62 | 2025-04-29 09:59:03 -0700 | [diff] [blame] | 167 | // SetConvo sets m.ConversationID and m.ParentConversationID based on convo. |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 168 | func (m *AgentMessage) SetConvo(convo *conversation.Convo) { |
| Josh Bleecher Snyder | 50a1d62 | 2025-04-29 09:59:03 -0700 | [diff] [blame] | 169 | 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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 180 | // GitCommit represents a single git commit for a commit message |
| 181 | type 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 |
| 189 | type ToolCall struct { |
| Sean McCullough | d9f1337 | 2025-04-21 15:08:49 -0700 | [diff] [blame] | 190 | 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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 196 | } |
| 197 | |
| 198 | func (a *AgentMessage) Attr() slog.Attr { |
| 199 | var attrs []any = []any{ |
| 200 | slog.String("type", string(a.Type)), |
| 201 | } |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 202 | attrs = append(attrs, slog.Int("idx", a.Idx)) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 203 | 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 | |
| 251 | func 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 | |
| 260 | func budgetMessage(err error) AgentMessage { |
| 261 | return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true} |
| 262 | } |
| 263 | |
| 264 | // ConvoInterface defines the interface for conversation interactions |
| 265 | type ConvoInterface interface { |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 266 | CumulativeUsage() conversation.CumulativeUsage |
| 267 | ResetBudget(conversation.Budget) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 268 | OverBudget() error |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 269 | SendMessage(message llm.Message) (*llm.Response, error) |
| 270 | SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error) |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 271 | GetID() string |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 272 | ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error) |
| 273 | ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 274 | CancelToolUse(toolUseID string, cause error) error |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 275 | SubConvoWithHistory() *conversation.Convo |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 276 | } |
| 277 | |
| 278 | type Agent struct { |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 279 | 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 Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 288 | outsideHTTP string // base address of the outside webserver (only when under docker) |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 289 | ready chan struct{} // closed when the agent is initialized (only when under docker) |
| 290 | startedAt time.Time |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 291 | originalBudget conversation.Budget |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 292 | title string |
| 293 | branchName string |
| 294 | codereview *claudetool.CodeReviewer |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 295 | // State machine to track agent state |
| 296 | stateMachine *StateMachine |
| Philip Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 297 | // Outside information |
| 298 | outsideHostname string |
| 299 | outsideOS string |
| 300 | outsideWorkingDir string |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 301 | // URL of the git remote 'origin' if it exists |
| 302 | gitOrigin string |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 303 | |
| 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 McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 313 | // protects cancelTurn |
| 314 | cancelTurnMu sync.Mutex |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 315 | // cancels potentially long-running tool_use calls or chains of them |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 316 | cancelTurn context.CancelCauseFunc |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 317 | |
| 318 | // protects following |
| 319 | mu sync.Mutex |
| 320 | |
| 321 | // Stores all messages for this agent |
| 322 | history []AgentMessage |
| 323 | |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 324 | // Iterators add themselves here when they're ready to be notified of new messages. |
| 325 | subscribers []chan *AgentMessage |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 326 | |
| 327 | // Track git commits we've already seen (by hash) |
| 328 | seenCommits map[string]bool |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 329 | |
| 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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 335 | } |
| 336 | |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 337 | // NewIterator implements CodingAgent. |
| 338 | func (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 | |
| 350 | type MessageIteratorImpl struct { |
| 351 | agent *Agent |
| 352 | ctx context.Context |
| 353 | nextMessageIdx int |
| 354 | ch chan *AgentMessage |
| 355 | subscribed bool |
| 356 | } |
| 357 | |
| 358 | func (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 | |
| 368 | func (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 McCullough | d9d4581 | 2025-04-30 16:53:41 -0700 | [diff] [blame] | 411 | // Assert that Agent satisfies the CodingAgent interface. |
| 412 | var _ CodingAgent = &Agent{} |
| 413 | |
| 414 | // StateName implements CodingAgent. |
| 415 | func (a *Agent) CurrentStateName() string { |
| 416 | if a.stateMachine == nil { |
| 417 | return "" |
| 418 | } |
| 419 | return a.stateMachine.currentState.String() |
| 420 | } |
| 421 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 422 | func (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. |
| 426 | func (a *Agent) Title() string { |
| 427 | a.mu.Lock() |
| 428 | defer a.mu.Unlock() |
| 429 | return a.title |
| 430 | } |
| 431 | |
| Josh Bleecher Snyder | 47b1936 | 2025-04-30 01:34:14 +0000 | [diff] [blame] | 432 | // BranchName returns the git branch name for the conversation. |
| 433 | func (a *Agent) BranchName() string { |
| 434 | a.mu.Lock() |
| 435 | defer a.mu.Unlock() |
| 436 | return a.branchName |
| 437 | } |
| 438 | |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 439 | // OutstandingLLMCallCount returns the number of outstanding LLM calls. |
| 440 | func (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. |
| 447 | func (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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 458 | // OS returns the operating system of the client. |
| 459 | func (a *Agent) OS() string { |
| 460 | return a.config.ClientGOOS |
| 461 | } |
| 462 | |
| Philip Zeyliger | c72fff5 | 2025-04-29 20:17:54 +0000 | [diff] [blame] | 463 | func (a *Agent) SessionID() string { |
| 464 | return a.config.SessionID |
| 465 | } |
| 466 | |
| Philip Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 467 | // OutsideOS returns the operating system of the outside system. |
| 468 | func (a *Agent) OutsideOS() string { |
| 469 | return a.outsideOS |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 470 | } |
| 471 | |
| Philip Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 472 | // OutsideHostname returns the hostname of the outside system. |
| 473 | func (a *Agent) OutsideHostname() string { |
| 474 | return a.outsideHostname |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 475 | } |
| 476 | |
| Philip Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 477 | // OutsideWorkingDir returns the working directory on the outside system. |
| 478 | func (a *Agent) OutsideWorkingDir() string { |
| 479 | return a.outsideWorkingDir |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 480 | } |
| 481 | |
| 482 | // GitOrigin returns the URL of the git remote 'origin' if it exists. |
| 483 | func (a *Agent) GitOrigin() string { |
| 484 | return a.gitOrigin |
| 485 | } |
| 486 | |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 487 | func (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 McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 508 | // CurrentState returns the current state of the agent's state machine. |
| 509 | func (a *Agent) CurrentState() State { |
| 510 | return a.stateMachine.CurrentState() |
| 511 | } |
| 512 | |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 513 | func (a *Agent) IsInContainer() bool { |
| 514 | return a.config.InDocker |
| 515 | } |
| 516 | |
| 517 | func (a *Agent) FirstMessageIndex() int { |
| 518 | a.mu.Lock() |
| 519 | defer a.mu.Unlock() |
| 520 | return a.firstMessageIndex |
| 521 | } |
| 522 | |
| Josh Bleecher Snyder | a9b3822 | 2025-04-29 18:05:06 -0700 | [diff] [blame] | 523 | // SetTitleBranch sets the title and branch name of the conversation. |
| 524 | func (a *Agent) SetTitleBranch(title, branchName string) { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 525 | a.mu.Lock() |
| 526 | defer a.mu.Unlock() |
| 527 | a.title = title |
| Josh Bleecher Snyder | a9b3822 | 2025-04-29 18:05:06 -0700 | [diff] [blame] | 528 | a.branchName = branchName |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 529 | |
| 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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 532 | } |
| 533 | |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 534 | // OnToolCall implements ant.Listener and tracks the start of a tool call. |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 535 | func (a *Agent) OnToolCall(ctx context.Context, convo *conversation.Convo, id string, toolName string, toolInput json.RawMessage, content llm.Content) { |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 536 | // Track the tool call |
| 537 | a.mu.Lock() |
| 538 | a.outstandingToolCalls[id] = toolName |
| 539 | a.mu.Unlock() |
| 540 | } |
| 541 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 542 | // OnToolResult implements ant.Listener. |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 543 | func (a *Agent) OnToolResult(ctx context.Context, convo *conversation.Convo, toolID string, toolName string, toolInput json.RawMessage, content llm.Content, result *string, err error) { |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 544 | // Remove the tool call from outstanding calls |
| 545 | a.mu.Lock() |
| 546 | delete(a.outstandingToolCalls, toolID) |
| 547 | a.mu.Unlock() |
| 548 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 549 | 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 Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 557 | StartTime: content.ToolUseStartTime, |
| 558 | EndTime: content.ToolUseEndTime, |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 559 | } |
| 560 | |
| 561 | // Calculate the elapsed time if both start and end times are set |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 562 | if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil { |
| 563 | elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 564 | m.Elapsed = &elapsed |
| 565 | } |
| 566 | |
| Josh Bleecher Snyder | 50a1d62 | 2025-04-29 09:59:03 -0700 | [diff] [blame] | 567 | m.SetConvo(convo) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 568 | a.pushToOutbox(ctx, m) |
| 569 | } |
| 570 | |
| 571 | // OnRequest implements ant.Listener. |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 572 | func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) { |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 573 | a.mu.Lock() |
| 574 | defer a.mu.Unlock() |
| 575 | a.outstandingLLMCalls[id] = struct{}{} |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 576 | // We already get tool results from the above. We send user messages to the outbox in the agent loop. |
| 577 | } |
| 578 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 579 | // OnResponse implements conversation.Listener. Responses contain messages from the LLM |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 580 | // 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 Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 583 | func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) { |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 584 | // Remove the LLM call from outstanding calls |
| 585 | a.mu.Lock() |
| 586 | delete(a.outstandingLLMCalls, id) |
| 587 | a.mu.Unlock() |
| 588 | |
| Josh Bleecher Snyder | 50a1d62 | 2025-04-29 09:59:03 -0700 | [diff] [blame] | 589 | 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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 600 | endOfTurn := false |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 601 | if resp.StopReason != llm.StopReasonToolUse && convo.Parent == nil { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 602 | 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 Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 614 | if resp.StopReason == llm.StopReasonToolUse { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 615 | var toolCalls []ToolCall |
| 616 | for _, part := range resp.Content { |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 617 | if part.Type == llm.ContentTypeToolUse { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 618 | 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 Snyder | 50a1d62 | 2025-04-29 09:59:03 -0700 | [diff] [blame] | 634 | m.SetConvo(convo) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 635 | a.pushToOutbox(ctx, m) |
| 636 | } |
| 637 | |
| 638 | // WorkingDir implements CodingAgent. |
| 639 | func (a *Agent) WorkingDir() string { |
| 640 | return a.workingDir |
| 641 | } |
| 642 | |
| 643 | // MessageCount implements CodingAgent. |
| 644 | func (a *Agent) MessageCount() int { |
| 645 | a.mu.Lock() |
| 646 | defer a.mu.Unlock() |
| 647 | return len(a.history) |
| 648 | } |
| 649 | |
| 650 | // Messages implements CodingAgent. |
| 651 | func (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 Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 657 | func (a *Agent) OriginalBudget() conversation.Budget { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 658 | return a.originalBudget |
| 659 | } |
| 660 | |
| 661 | // AgentConfig contains configuration for creating a new Agent. |
| 662 | type AgentConfig struct { |
| 663 | Context context.Context |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 664 | Service llm.Service |
| 665 | Budget conversation.Budget |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 666 | GitUsername string |
| 667 | GitEmail string |
| 668 | SessionID string |
| 669 | ClientGOOS string |
| 670 | ClientGOARCH string |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 671 | InDocker bool |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 672 | UseAnthropicEdit bool |
| Philip Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 673 | // Outside information |
| 674 | OutsideHostname string |
| 675 | OutsideOS string |
| 676 | OutsideWorkingDir string |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 677 | } |
| 678 | |
| 679 | // NewAgent creates a new Agent. |
| 680 | // It is not usable until Init() is called. |
| 681 | func NewAgent(config AgentConfig) *Agent { |
| 682 | agent := &Agent{ |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 683 | config: config, |
| 684 | ready: make(chan struct{}), |
| 685 | inbox: make(chan string, 100), |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 686 | subscribers: make([]chan *AgentMessage, 0), |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 687 | 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 McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 695 | stateMachine: NewStateMachine(), |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 696 | } |
| 697 | return agent |
| 698 | } |
| 699 | |
| 700 | type AgentInit struct { |
| 701 | WorkingDir string |
| 702 | NoGit bool // only for testing |
| 703 | |
| 704 | InDocker bool |
| 705 | Commit string |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 706 | OutsideHTTP string |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 707 | GitRemoteAddr string |
| 708 | HostAddr string |
| 709 | } |
| 710 | |
| 711 | func (a *Agent) Init(ini AgentInit) error { |
| Josh Bleecher Snyder | 9c07e1d | 2025-04-28 19:25:37 -0700 | [diff] [blame] | 712 | if a.convo != nil { |
| 713 | return fmt.Errorf("Agent.Init: already initialized") |
| 714 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 715 | 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 Zeyliger | d0ac1ea | 2025-04-21 20:04:19 -0700 | [diff] [blame] | 722 | 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 Snyder | 76ccdfd | 2025-05-01 17:14:18 +0000 | [diff] [blame] | 727 | cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host") |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 728 | 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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 737 | a.lastHEAD = ini.Commit |
| 738 | a.gitRemoteAddr = ini.GitRemoteAddr |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 739 | a.outsideHTTP = ini.OutsideHTTP |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 740 | 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 Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 765 | |
| 766 | a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 767 | } |
| 768 | a.lastHEAD = a.initialCommit |
| 769 | a.convo = a.initConvo() |
| 770 | close(a.ready) |
| 771 | return nil |
| 772 | } |
| 773 | |
| Josh Bleecher Snyder | dbe0230 | 2025-04-29 16:44:23 -0700 | [diff] [blame] | 774 | //go:embed agent_system_prompt.txt |
| 775 | var agentSystemPrompt string |
| 776 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 777 | // initConvo initializes the conversation. |
| 778 | // It must not be called until all agent fields are initialized, |
| 779 | // particularly workingDir and git. |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 780 | func (a *Agent) initConvo() *conversation.Convo { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 781 | ctx := a.config.Context |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 782 | convo := conversation.New(ctx, a.config.Service) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 783 | 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 Snyder | dbe0230 | 2025-04-29 16:44:23 -0700 | [diff] [blame] | 793 | convo.SystemPrompt = fmt.Sprintf(agentSystemPrompt, editPrompt, a.config.ClientGOOS, a.config.ClientGOARCH, a.workingDir, a.repoRoot, a.initialCommit) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 794 | |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 795 | // 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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 825 | // 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 Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 828 | convo.Tools = []*llm.Tool{ |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 829 | bashTool, claudetool.Keyword, |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 830 | claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail), |
| Sean McCullough | 485afc6 | 2025-04-28 14:28:39 -0700 | [diff] [blame] | 831 | a.codereview.Tool(), a.multipleChoiceTool(), |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 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 | |
| Sean McCullough | 485afc6 | 2025-04-28 14:28:39 -0700 | [diff] [blame] | 842 | func (a *Agent) multipleChoiceTool() *llm.Tool { |
| 843 | ret := &llm.Tool{ |
| 844 | Name: "multiplechoice", |
| 845 | Description: "Present the user with an quick way to answer to your question using one of a short list of possible answers you would expect from the user.", |
| 846 | InputSchema: json.RawMessage(`{ |
| 847 | "type": "object", |
| 848 | "description": "The question and a list of answers you would expect the user to choose from.", |
| 849 | "properties": { |
| 850 | "question": { |
| 851 | "type": "string", |
| 852 | "description": "The text of the multiple-choice question you would like the user, e.g. 'What kinds of test cases would you like me to add?'" |
| 853 | }, |
| 854 | "responseOptions": { |
| 855 | "type": "array", |
| 856 | "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].", |
| 857 | "items": { |
| 858 | "type": "object", |
| 859 | "properties": { |
| 860 | "caption": { |
| 861 | "type": "string", |
| 862 | "description": "The caption, e.g. 'Basic coverage', 'Error return values', or 'Malformed input' for the response button. Do NOT include options for responses that would end the conversation like 'Ok', 'No thank you', 'This looks good'" |
| 863 | }, |
| 864 | "responseText": { |
| 865 | "type": "string", |
| 866 | "description": "The full text of the response, e.g. 'Add unit tests for basic test coverage', 'Add unit tests for error return values', or 'Add unit tests for malformed input'" |
| 867 | } |
| 868 | }, |
| 869 | "required": ["caption", "responseText"] |
| 870 | } |
| 871 | } |
| 872 | }, |
| 873 | "required": ["question", "responseOptions"] |
| 874 | }`), |
| 875 | Run: func(ctx context.Context, input json.RawMessage) (string, error) { |
| 876 | // The Run logic for "multiplchoice" tool is a no-op on the server. |
| 877 | // The UI will present a list of options for the user to select from, |
| 878 | // and that's it as far as "executing" the tool_use goes. |
| 879 | // When the user *does* select one of the presented options, that |
| 880 | // responseText gets sent as a chat message on behalf of the user. |
| 881 | return "end your turn and wait for the user to respond", nil |
| 882 | }, |
| 883 | } |
| 884 | return ret |
| 885 | } |
| 886 | |
| 887 | type MultipleChoiceOption struct { |
| 888 | Caption string `json:"caption"` |
| 889 | ResponseText string `json:"responseText"` |
| 890 | } |
| 891 | |
| 892 | type MultipleChoiceParams struct { |
| 893 | Question string `json:"question"` |
| 894 | ResponseOptions []MultipleChoiceOption `json:"responseOptions"` |
| 895 | } |
| 896 | |
| Josh Bleecher Snyder | fff269b | 2025-04-30 01:49:39 +0000 | [diff] [blame] | 897 | // branchExists reports whether branchName exists, either locally or in well-known remotes. |
| 898 | func branchExists(dir, branchName string) bool { |
| 899 | refs := []string{ |
| 900 | "refs/heads/", |
| 901 | "refs/remotes/origin/", |
| 902 | "refs/remotes/sketch-host/", |
| 903 | } |
| 904 | for _, ref := range refs { |
| 905 | cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName) |
| 906 | cmd.Dir = dir |
| 907 | if cmd.Run() == nil { // exit code 0 means branch exists |
| 908 | return true |
| 909 | } |
| 910 | } |
| 911 | return false |
| 912 | } |
| 913 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 914 | func (a *Agent) titleTool() *llm.Tool { |
| 915 | title := &llm.Tool{ |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 916 | Name: "title", |
| Josh Bleecher Snyder | a9b3822 | 2025-04-29 18:05:06 -0700 | [diff] [blame] | 917 | 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 Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 918 | InputSchema: json.RawMessage(`{ |
| 919 | "type": "object", |
| 920 | "properties": { |
| 921 | "title": { |
| 922 | "type": "string", |
| Josh Bleecher Snyder | 250348e | 2025-04-30 10:31:28 -0700 | [diff] [blame] | 923 | "description": "A concise title summarizing what this conversation is about, imperative tense preferred" |
| Josh Bleecher Snyder | a9b3822 | 2025-04-29 18:05:06 -0700 | [diff] [blame] | 924 | }, |
| 925 | "branch_name": { |
| 926 | "type": "string", |
| 927 | "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 928 | } |
| 929 | }, |
| Josh Bleecher Snyder | a9b3822 | 2025-04-29 18:05:06 -0700 | [diff] [blame] | 930 | "required": ["title", "branch_name"] |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 931 | }`), |
| 932 | Run: func(ctx context.Context, input json.RawMessage) (string, error) { |
| 933 | var params struct { |
| Josh Bleecher Snyder | a9b3822 | 2025-04-29 18:05:06 -0700 | [diff] [blame] | 934 | Title string `json:"title"` |
| 935 | BranchName string `json:"branch_name"` |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 936 | } |
| 937 | if err := json.Unmarshal(input, ¶ms); err != nil { |
| 938 | return "", err |
| 939 | } |
| Josh Bleecher Snyder | a9b3822 | 2025-04-29 18:05:06 -0700 | [diff] [blame] | 940 | // It's unfortunate to not allow title changes, |
| 941 | // but it avoids having multiple branches. |
| 942 | t := a.Title() |
| 943 | if t != "" { |
| 944 | return "", fmt.Errorf("title already set to: %s", t) |
| 945 | } |
| 946 | |
| 947 | if params.BranchName == "" { |
| 948 | return "", fmt.Errorf("branch_name parameter cannot be empty") |
| 949 | } |
| 950 | if params.Title == "" { |
| 951 | return "", fmt.Errorf("title parameter cannot be empty") |
| 952 | } |
| Josh Bleecher Snyder | 42f7a7c | 2025-04-30 10:29:21 -0700 | [diff] [blame] | 953 | if params.BranchName != cleanBranchName(params.BranchName) { |
| 954 | return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug") |
| 955 | } |
| 956 | branchName := "sketch/" + params.BranchName |
| Josh Bleecher Snyder | fff269b | 2025-04-30 01:49:39 +0000 | [diff] [blame] | 957 | if branchExists(a.workingDir, branchName) { |
| 958 | return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName) |
| 959 | } |
| 960 | |
| Josh Bleecher Snyder | a9b3822 | 2025-04-29 18:05:06 -0700 | [diff] [blame] | 961 | a.SetTitleBranch(params.Title, branchName) |
| 962 | |
| 963 | response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName) |
| 964 | return response, nil |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 965 | }, |
| 966 | } |
| 967 | return title |
| 968 | } |
| 969 | |
| 970 | func (a *Agent) Ready() <-chan struct{} { |
| 971 | return a.ready |
| 972 | } |
| 973 | |
| 974 | func (a *Agent) UserMessage(ctx context.Context, msg string) { |
| 975 | a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg}) |
| 976 | a.inbox <- msg |
| 977 | } |
| 978 | |
| Sean McCullough | 485afc6 | 2025-04-28 14:28:39 -0700 | [diff] [blame] | 979 | func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) { |
| 980 | a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID}) |
| 981 | a.inbox <- msg |
| 982 | } |
| 983 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 984 | func (a *Agent) CancelToolUse(toolUseID string, cause error) error { |
| 985 | return a.convo.CancelToolUse(toolUseID, cause) |
| 986 | } |
| 987 | |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 988 | func (a *Agent) CancelTurn(cause error) { |
| 989 | a.cancelTurnMu.Lock() |
| 990 | defer a.cancelTurnMu.Unlock() |
| 991 | if a.cancelTurn != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 992 | // Force state transition to cancelled state |
| 993 | ctx := a.config.Context |
| 994 | a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error()) |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 995 | a.cancelTurn(cause) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 996 | } |
| 997 | } |
| 998 | |
| 999 | func (a *Agent) Loop(ctxOuter context.Context) { |
| 1000 | for { |
| 1001 | select { |
| 1002 | case <-ctxOuter.Done(): |
| 1003 | return |
| 1004 | default: |
| 1005 | ctxInner, cancel := context.WithCancelCause(ctxOuter) |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 1006 | a.cancelTurnMu.Lock() |
| 1007 | // Set .cancelTurn so the user can cancel whatever is happening |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1008 | // inside the conversation loop without canceling this outer Loop execution. |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 1009 | // This cancelTurn func is intended be called from other goroutines, |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1010 | // hence the mutex. |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 1011 | a.cancelTurn = cancel |
| 1012 | a.cancelTurnMu.Unlock() |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1013 | err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose |
| 1014 | if err != nil { |
| 1015 | slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err) |
| 1016 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1017 | cancel(nil) |
| 1018 | } |
| 1019 | } |
| 1020 | } |
| 1021 | |
| 1022 | func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) { |
| 1023 | if m.Timestamp.IsZero() { |
| 1024 | m.Timestamp = time.Now() |
| 1025 | } |
| 1026 | |
| 1027 | // If this is an end-of-turn message, calculate the turn duration and add it to the message |
| 1028 | if m.EndOfTurn && m.Type == AgentMessageType { |
| 1029 | turnDuration := time.Since(a.startOfTurn) |
| 1030 | m.TurnDuration = &turnDuration |
| 1031 | slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration) |
| 1032 | } |
| 1033 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1034 | a.mu.Lock() |
| 1035 | defer a.mu.Unlock() |
| 1036 | m.Idx = len(a.history) |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 1037 | slog.InfoContext(ctx, "agent message", m.Attr()) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1038 | a.history = append(a.history, m) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1039 | |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 1040 | // Notify all subscribers |
| 1041 | for _, ch := range a.subscribers { |
| 1042 | ch <- &m |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1043 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1044 | } |
| 1045 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1046 | func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) { |
| 1047 | var m []llm.Content |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1048 | if block { |
| 1049 | select { |
| 1050 | case <-ctx.Done(): |
| 1051 | return m, ctx.Err() |
| 1052 | case msg := <-a.inbox: |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1053 | m = append(m, llm.StringContent(msg)) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1054 | } |
| 1055 | } |
| 1056 | for { |
| 1057 | select { |
| 1058 | case msg := <-a.inbox: |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1059 | m = append(m, llm.StringContent(msg)) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1060 | default: |
| 1061 | return m, nil |
| 1062 | } |
| 1063 | } |
| 1064 | } |
| 1065 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1066 | // processTurn handles a single conversation turn with the user |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1067 | func (a *Agent) processTurn(ctx context.Context) error { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1068 | // Reset the start of turn time |
| 1069 | a.startOfTurn = time.Now() |
| 1070 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1071 | // Transition to waiting for user input state |
| 1072 | a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn") |
| 1073 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1074 | // Process initial user message |
| 1075 | initialResp, err := a.processUserMessage(ctx) |
| 1076 | if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1077 | a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error()) |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1078 | return err |
| 1079 | } |
| 1080 | |
| 1081 | // Handle edge case where both initialResp and err are nil |
| 1082 | if initialResp == nil { |
| 1083 | err := fmt.Errorf("unexpected nil response from processUserMessage with no error") |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1084 | a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error()) |
| 1085 | |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1086 | a.pushToOutbox(ctx, errorMessage(err)) |
| 1087 | return err |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1088 | } |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1089 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1090 | // We do this as we go, but let's also do it at the end of the turn |
| 1091 | defer func() { |
| 1092 | if _, err := a.handleGitCommits(ctx); err != nil { |
| 1093 | // Just log the error, don't stop execution |
| 1094 | slog.WarnContext(ctx, "Failed to check for new git commits", "error", err) |
| 1095 | } |
| 1096 | }() |
| 1097 | |
| Sean McCullough | a1e0e49 | 2025-05-01 10:51:08 -0700 | [diff] [blame] | 1098 | // Main response loop - continue as long as the model is using tools or a tool use fails. |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1099 | resp := initialResp |
| 1100 | for { |
| 1101 | // Check if we are over budget |
| 1102 | if err := a.overBudget(ctx); err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1103 | a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error()) |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1104 | return err |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1105 | } |
| 1106 | |
| 1107 | // If the model is not requesting to use a tool, we're done |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1108 | if resp.StopReason != llm.StopReasonToolUse { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1109 | a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1110 | break |
| 1111 | } |
| 1112 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1113 | // Transition to tool use requested state |
| 1114 | a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use") |
| 1115 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1116 | // Handle tool execution |
| 1117 | continueConversation, toolResp := a.handleToolExecution(ctx, resp) |
| 1118 | if !continueConversation { |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1119 | return nil |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1120 | } |
| 1121 | |
| Sean McCullough | a1e0e49 | 2025-05-01 10:51:08 -0700 | [diff] [blame] | 1122 | if toolResp == nil { |
| 1123 | return fmt.Errorf("cannot continue conversation with a nil tool response") |
| 1124 | } |
| 1125 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1126 | // Set the response for the next iteration |
| 1127 | resp = toolResp |
| 1128 | } |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1129 | |
| 1130 | return nil |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1131 | } |
| 1132 | |
| 1133 | // processUserMessage waits for user messages and sends them to the model |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1134 | func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) { |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1135 | // Wait for at least one message from the user |
| 1136 | msgs, err := a.GatherMessages(ctx, true) |
| 1137 | if err != nil { // e.g. the context was canceled while blocking in GatherMessages |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1138 | a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1139 | return nil, err |
| 1140 | } |
| 1141 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1142 | userMessage := llm.Message{ |
| 1143 | Role: llm.MessageRoleUser, |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1144 | Content: msgs, |
| 1145 | } |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1146 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1147 | // Transition to sending to LLM state |
| 1148 | a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM") |
| 1149 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1150 | // Send message to the model |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1151 | resp, err := a.convo.SendMessage(userMessage) |
| 1152 | if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1153 | a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error()) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1154 | a.pushToOutbox(ctx, errorMessage(err)) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1155 | return nil, err |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1156 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1157 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1158 | // Transition to processing LLM response state |
| 1159 | a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response") |
| 1160 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1161 | return resp, nil |
| 1162 | } |
| 1163 | |
| 1164 | // handleToolExecution processes a tool use request from the model |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1165 | func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) { |
| 1166 | var results []llm.Content |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1167 | cancelled := false |
| 1168 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1169 | // Transition to checking for cancellation state |
| 1170 | a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation") |
| 1171 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1172 | // Check if the operation was cancelled by the user |
| 1173 | select { |
| 1174 | case <-ctx.Done(): |
| 1175 | // Don't actually run any of the tools, but rather build a response |
| 1176 | // for each tool_use message letting the LLM know that user canceled it. |
| 1177 | var err error |
| 1178 | results, err = a.convo.ToolResultCancelContents(resp) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1179 | if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1180 | a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1181 | a.pushToOutbox(ctx, errorMessage(err)) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1182 | } |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1183 | cancelled = true |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1184 | a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1185 | default: |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1186 | // Transition to running tool state |
| 1187 | a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool") |
| 1188 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1189 | // Add working directory to context for tool execution |
| 1190 | ctx = claudetool.WithWorkingDir(ctx, a.workingDir) |
| 1191 | |
| 1192 | // Execute the tools |
| 1193 | var err error |
| 1194 | results, err = a.convo.ToolResultContents(ctx, resp) |
| 1195 | if ctx.Err() != nil { // e.g. the user canceled the operation |
| 1196 | cancelled = true |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1197 | a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1198 | } else if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1199 | a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1200 | a.pushToOutbox(ctx, errorMessage(err)) |
| 1201 | } |
| 1202 | } |
| 1203 | |
| 1204 | // Process git commits that may have occurred during tool execution |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1205 | a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1206 | autoqualityMessages := a.processGitChanges(ctx) |
| 1207 | |
| 1208 | // Check budget again after tool execution |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1209 | a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1210 | if err := a.overBudget(ctx); err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1211 | a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1212 | return false, nil |
| 1213 | } |
| 1214 | |
| 1215 | // Continue the conversation with tool results and any user messages |
| 1216 | return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled) |
| 1217 | } |
| 1218 | |
| 1219 | // processGitChanges checks for new git commits and runs autoformatters if needed |
| 1220 | func (a *Agent) processGitChanges(ctx context.Context) []string { |
| 1221 | // Check for git commits after tool execution |
| 1222 | newCommits, err := a.handleGitCommits(ctx) |
| 1223 | if err != nil { |
| 1224 | // Just log the error, don't stop execution |
| 1225 | slog.WarnContext(ctx, "Failed to check for new git commits", "error", err) |
| 1226 | return nil |
| 1227 | } |
| 1228 | |
| 1229 | // Run autoformatters if there was exactly one new commit |
| 1230 | var autoqualityMessages []string |
| 1231 | if len(newCommits) == 1 { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1232 | a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running autoformatters on new commit") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1233 | formatted := a.codereview.Autoformat(ctx) |
| 1234 | if len(formatted) > 0 { |
| 1235 | msg := fmt.Sprintf(` |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1236 | I ran autoformatters and they updated these files: |
| 1237 | |
| 1238 | %s |
| 1239 | |
| 1240 | Please amend your latest git commit with these changes and then continue with what you were doing.`, |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1241 | strings.Join(formatted, "\n"), |
| 1242 | )[1:] |
| 1243 | a.pushToOutbox(ctx, AgentMessage{ |
| 1244 | Type: AutoMessageType, |
| 1245 | Content: msg, |
| 1246 | Timestamp: time.Now(), |
| 1247 | }) |
| 1248 | autoqualityMessages = append(autoqualityMessages, msg) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1249 | } |
| 1250 | } |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1251 | |
| 1252 | return autoqualityMessages |
| 1253 | } |
| 1254 | |
| 1255 | // continueTurnWithToolResults continues the conversation with tool results |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1256 | func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) { |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1257 | // Get any messages the user sent while tools were executing |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1258 | a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1259 | msgs, err := a.GatherMessages(ctx, false) |
| 1260 | if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1261 | a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1262 | return false, nil |
| 1263 | } |
| 1264 | |
| 1265 | // Inject any auto-generated messages from quality checks |
| 1266 | for _, msg := range autoqualityMessages { |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1267 | msgs = append(msgs, llm.StringContent(msg)) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1268 | } |
| 1269 | |
| 1270 | // Handle cancellation by appending a message about it |
| 1271 | if cancelled { |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1272 | msgs = append(msgs, llm.StringContent(cancelToolUseMessage)) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1273 | // EndOfTurn is false here so that the client of this agent keeps processing |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 1274 | // further messages; the conversation is not over. |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1275 | a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false}) |
| 1276 | } else if err := a.convo.OverBudget(); err != nil { |
| 1277 | // Handle budget issues by appending a message about it |
| 1278 | budgetMsg := "We've exceeded our budget. Please ask the user to confirm before continuing by ending the turn." |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1279 | msgs = append(msgs, llm.StringContent(budgetMsg)) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1280 | a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err))) |
| 1281 | } |
| 1282 | |
| 1283 | // Combine tool results with user messages |
| 1284 | results = append(results, msgs...) |
| 1285 | |
| 1286 | // Send the combined message to continue the conversation |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1287 | a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM") |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1288 | resp, err := a.convo.SendMessage(llm.Message{ |
| 1289 | Role: llm.MessageRoleUser, |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1290 | Content: results, |
| 1291 | }) |
| 1292 | if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1293 | a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1294 | a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error()))) |
| 1295 | return true, nil // Return true to continue the conversation, but with no response |
| 1296 | } |
| 1297 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1298 | // Transition back to processing LLM response |
| 1299 | a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results") |
| 1300 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1301 | if cancelled { |
| 1302 | return false, nil |
| 1303 | } |
| 1304 | |
| 1305 | return true, resp |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1306 | } |
| 1307 | |
| 1308 | func (a *Agent) overBudget(ctx context.Context) error { |
| 1309 | if err := a.convo.OverBudget(); err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1310 | a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error()) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1311 | m := budgetMessage(err) |
| 1312 | m.Content = m.Content + "\n\nBudget reset." |
| 1313 | a.pushToOutbox(ctx, budgetMessage(err)) |
| 1314 | a.convo.ResetBudget(a.originalBudget) |
| 1315 | return err |
| 1316 | } |
| 1317 | return nil |
| 1318 | } |
| 1319 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1320 | func collectTextContent(msg *llm.Response) string { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1321 | // Collect all text content |
| 1322 | var allText strings.Builder |
| 1323 | for _, content := range msg.Content { |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1324 | if content.Type == llm.ContentTypeText && content.Text != "" { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1325 | if allText.Len() > 0 { |
| 1326 | allText.WriteString("\n\n") |
| 1327 | } |
| 1328 | allText.WriteString(content.Text) |
| 1329 | } |
| 1330 | } |
| 1331 | return allText.String() |
| 1332 | } |
| 1333 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1334 | func (a *Agent) TotalUsage() conversation.CumulativeUsage { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1335 | a.mu.Lock() |
| 1336 | defer a.mu.Unlock() |
| 1337 | return a.convo.CumulativeUsage() |
| 1338 | } |
| 1339 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1340 | // Diff returns a unified diff of changes made since the agent was instantiated. |
| 1341 | func (a *Agent) Diff(commit *string) (string, error) { |
| 1342 | if a.initialCommit == "" { |
| 1343 | return "", fmt.Errorf("no initial commit reference available") |
| 1344 | } |
| 1345 | |
| 1346 | // Find the repository root |
| 1347 | ctx := context.Background() |
| 1348 | |
| 1349 | // If a specific commit hash is provided, show just that commit's changes |
| 1350 | if commit != nil && *commit != "" { |
| 1351 | // Validate that the commit looks like a valid git SHA |
| 1352 | if !isValidGitSHA(*commit) { |
| 1353 | return "", fmt.Errorf("invalid git commit SHA format: %s", *commit) |
| 1354 | } |
| 1355 | |
| 1356 | // Get the diff for just this commit |
| 1357 | cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit) |
| 1358 | cmd.Dir = a.repoRoot |
| 1359 | output, err := cmd.CombinedOutput() |
| 1360 | if err != nil { |
| 1361 | return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output)) |
| 1362 | } |
| 1363 | return string(output), nil |
| 1364 | } |
| 1365 | |
| 1366 | // Otherwise, get the diff between the initial commit and the current state using exec.Command |
| 1367 | cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit) |
| 1368 | cmd.Dir = a.repoRoot |
| 1369 | output, err := cmd.CombinedOutput() |
| 1370 | if err != nil { |
| 1371 | return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output)) |
| 1372 | } |
| 1373 | |
| 1374 | return string(output), nil |
| 1375 | } |
| 1376 | |
| 1377 | // InitialCommit returns the Git commit hash that was saved when the agent was instantiated. |
| 1378 | func (a *Agent) InitialCommit() string { |
| 1379 | return a.initialCommit |
| 1380 | } |
| 1381 | |
| 1382 | // handleGitCommits() highlights new commits to the user. When running |
| 1383 | // under docker, new HEADs are pushed to a branch according to the title. |
| 1384 | func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) { |
| 1385 | if a.repoRoot == "" { |
| 1386 | return nil, nil |
| 1387 | } |
| 1388 | |
| 1389 | head, err := resolveRef(ctx, a.repoRoot, "HEAD") |
| 1390 | if err != nil { |
| 1391 | return nil, err |
| 1392 | } |
| 1393 | if head == a.lastHEAD { |
| 1394 | return nil, nil // nothing to do |
| 1395 | } |
| 1396 | defer func() { |
| 1397 | a.lastHEAD = head |
| 1398 | }() |
| 1399 | |
| 1400 | // Get new commits. Because it's possible that the agent does rebases, fixups, and |
| 1401 | // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves |
| 1402 | // to the last 100 commits. |
| 1403 | var commits []*GitCommit |
| 1404 | |
| 1405 | // Get commits since the initial commit |
| 1406 | // Format: <hash>\0<subject>\0<body>\0 |
| 1407 | // This uses NULL bytes as separators to avoid issues with newlines in commit messages |
| 1408 | // Limit to 100 commits to avoid overwhelming the user |
| 1409 | cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head) |
| 1410 | cmd.Dir = a.repoRoot |
| 1411 | output, err := cmd.Output() |
| 1412 | if err != nil { |
| 1413 | return nil, fmt.Errorf("failed to get git log: %w", err) |
| 1414 | } |
| 1415 | |
| 1416 | // Parse git log output and filter out already seen commits |
| 1417 | parsedCommits := parseGitLog(string(output)) |
| 1418 | |
| 1419 | var headCommit *GitCommit |
| 1420 | |
| 1421 | // Filter out commits we've already seen |
| 1422 | for _, commit := range parsedCommits { |
| 1423 | if commit.Hash == head { |
| 1424 | headCommit = &commit |
| 1425 | } |
| 1426 | |
| 1427 | // Skip if we've seen this commit before. If our head has changed, always include that. |
| 1428 | if a.seenCommits[commit.Hash] && commit.Hash != head { |
| 1429 | continue |
| 1430 | } |
| 1431 | |
| 1432 | // Mark this commit as seen |
| 1433 | a.seenCommits[commit.Hash] = true |
| 1434 | |
| 1435 | // Add to our list of new commits |
| 1436 | commits = append(commits, &commit) |
| 1437 | } |
| 1438 | |
| 1439 | if a.gitRemoteAddr != "" { |
| 1440 | if headCommit == nil { |
| 1441 | // I think this can only happen if we have a bug or if there's a race. |
| 1442 | headCommit = &GitCommit{} |
| 1443 | headCommit.Hash = head |
| 1444 | headCommit.Subject = "unknown" |
| 1445 | commits = append(commits, headCommit) |
| 1446 | } |
| 1447 | |
| Josh Bleecher Snyder | a9b3822 | 2025-04-29 18:05:06 -0700 | [diff] [blame] | 1448 | branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1449 | |
| 1450 | // TODO: I don't love the force push here. We could see if the push is a fast-forward, and, |
| 1451 | // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and |
| 1452 | // then use push with lease to replace. |
| 1453 | cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch) |
| 1454 | cmd.Dir = a.workingDir |
| 1455 | if out, err := cmd.CombinedOutput(); err != nil { |
| 1456 | a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err))) |
| 1457 | } else { |
| 1458 | headCommit.PushedBranch = branch |
| 1459 | } |
| 1460 | } |
| 1461 | |
| 1462 | // If we found new commits, create a message |
| 1463 | if len(commits) > 0 { |
| 1464 | msg := AgentMessage{ |
| 1465 | Type: CommitMessageType, |
| 1466 | Timestamp: time.Now(), |
| 1467 | Commits: commits, |
| 1468 | } |
| 1469 | a.pushToOutbox(ctx, msg) |
| 1470 | } |
| 1471 | return commits, nil |
| 1472 | } |
| 1473 | |
| Josh Bleecher Snyder | a9b3822 | 2025-04-29 18:05:06 -0700 | [diff] [blame] | 1474 | func cleanBranchName(s string) string { |
| Josh Bleecher Snyder | 1ae976b | 2025-04-30 00:06:43 +0000 | [diff] [blame] | 1475 | return strings.Map(func(r rune) rune { |
| 1476 | // lowercase |
| 1477 | if r >= 'A' && r <= 'Z' { |
| 1478 | return r + 'a' - 'A' |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1479 | } |
| Josh Bleecher Snyder | 1ae976b | 2025-04-30 00:06:43 +0000 | [diff] [blame] | 1480 | // replace spaces with dashes |
| 1481 | if r == ' ' { |
| 1482 | return '-' |
| 1483 | } |
| 1484 | // allow alphanumerics and dashes |
| 1485 | if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') { |
| 1486 | return r |
| 1487 | } |
| 1488 | return -1 |
| 1489 | }, s) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1490 | } |
| 1491 | |
| 1492 | // parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00' |
| 1493 | // and returns an array of GitCommit structs. |
| 1494 | func parseGitLog(output string) []GitCommit { |
| 1495 | var commits []GitCommit |
| 1496 | |
| 1497 | // No output means no commits |
| 1498 | if len(output) == 0 { |
| 1499 | return commits |
| 1500 | } |
| 1501 | |
| 1502 | // Split by NULL byte |
| 1503 | parts := strings.Split(output, "\x00") |
| 1504 | |
| 1505 | // Process in triplets (hash, subject, body) |
| 1506 | for i := 0; i < len(parts); i++ { |
| 1507 | // Skip empty parts |
| 1508 | if parts[i] == "" { |
| 1509 | continue |
| 1510 | } |
| 1511 | |
| 1512 | // This should be a hash |
| 1513 | hash := strings.TrimSpace(parts[i]) |
| 1514 | |
| 1515 | // Make sure we have at least a subject part available |
| 1516 | if i+1 >= len(parts) { |
| 1517 | break // No more parts available |
| 1518 | } |
| 1519 | |
| 1520 | // Get the subject |
| 1521 | subject := strings.TrimSpace(parts[i+1]) |
| 1522 | |
| 1523 | // Get the body if available |
| 1524 | body := "" |
| 1525 | if i+2 < len(parts) { |
| 1526 | body = strings.TrimSpace(parts[i+2]) |
| 1527 | } |
| 1528 | |
| 1529 | // Skip to the next triplet |
| 1530 | i += 2 |
| 1531 | |
| 1532 | commits = append(commits, GitCommit{ |
| 1533 | Hash: hash, |
| 1534 | Subject: subject, |
| 1535 | Body: body, |
| 1536 | }) |
| 1537 | } |
| 1538 | |
| 1539 | return commits |
| 1540 | } |
| 1541 | |
| 1542 | func repoRoot(ctx context.Context, dir string) (string, error) { |
| 1543 | cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") |
| 1544 | stderr := new(strings.Builder) |
| 1545 | cmd.Stderr = stderr |
| 1546 | cmd.Dir = dir |
| 1547 | out, err := cmd.Output() |
| 1548 | if err != nil { |
| 1549 | return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr) |
| 1550 | } |
| 1551 | return strings.TrimSpace(string(out)), nil |
| 1552 | } |
| 1553 | |
| 1554 | func resolveRef(ctx context.Context, dir, refName string) (string, error) { |
| 1555 | cmd := exec.CommandContext(ctx, "git", "rev-parse", refName) |
| 1556 | stderr := new(strings.Builder) |
| 1557 | cmd.Stderr = stderr |
| 1558 | cmd.Dir = dir |
| 1559 | out, err := cmd.Output() |
| 1560 | if err != nil { |
| 1561 | return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr) |
| 1562 | } |
| 1563 | // TODO: validate that out is valid hex |
| 1564 | return strings.TrimSpace(string(out)), nil |
| 1565 | } |
| 1566 | |
| 1567 | // isValidGitSHA validates if a string looks like a valid git SHA hash. |
| 1568 | // Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters. |
| 1569 | func isValidGitSHA(sha string) bool { |
| 1570 | // Git SHA must be a hexadecimal string with at least 4 characters |
| 1571 | if len(sha) < 4 || len(sha) > 40 { |
| 1572 | return false |
| 1573 | } |
| 1574 | |
| 1575 | // Check if the string only contains hexadecimal characters |
| 1576 | for _, char := range sha { |
| 1577 | if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') { |
| 1578 | return false |
| 1579 | } |
| 1580 | } |
| 1581 | |
| 1582 | return true |
| 1583 | } |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 1584 | |
| 1585 | // getGitOrigin returns the URL of the git remote 'origin' if it exists |
| 1586 | func getGitOrigin(ctx context.Context, dir string) string { |
| 1587 | cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url") |
| 1588 | cmd.Dir = dir |
| 1589 | stderr := new(strings.Builder) |
| 1590 | cmd.Stderr = stderr |
| 1591 | out, err := cmd.Output() |
| 1592 | if err != nil { |
| 1593 | return "" |
| 1594 | } |
| 1595 | return strings.TrimSpace(string(out)) |
| 1596 | } |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 1597 | |
| 1598 | func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error { |
| 1599 | cmd := exec.CommandContext(ctx, "git", "stash") |
| 1600 | cmd.Dir = workingDir |
| 1601 | if out, err := cmd.CombinedOutput(); err != nil { |
| 1602 | return fmt.Errorf("git stash: %s: %v", out, err) |
| 1603 | } |
| Josh Bleecher Snyder | 76ccdfd | 2025-05-01 17:14:18 +0000 | [diff] [blame] | 1604 | cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host") |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 1605 | cmd.Dir = workingDir |
| 1606 | if out, err := cmd.CombinedOutput(); err != nil { |
| 1607 | return fmt.Errorf("git fetch: %s: %w", out, err) |
| 1608 | } |
| 1609 | cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision) |
| 1610 | cmd.Dir = workingDir |
| 1611 | if out, err := cmd.CombinedOutput(); err != nil { |
| 1612 | return fmt.Errorf("git checkout %s: %s: %w", revision, out, err) |
| 1613 | } |
| 1614 | a.lastHEAD = revision |
| 1615 | a.initialCommit = revision |
| 1616 | return nil |
| 1617 | } |
| 1618 | |
| 1619 | func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error { |
| 1620 | a.mu.Lock() |
| 1621 | a.title = "" |
| 1622 | a.firstMessageIndex = len(a.history) |
| 1623 | a.convo = a.initConvo() |
| 1624 | gitReset := func() error { |
| 1625 | if a.config.InDocker && rev != "" { |
| 1626 | err := a.initGitRevision(ctx, a.workingDir, rev) |
| 1627 | if err != nil { |
| 1628 | return err |
| 1629 | } |
| 1630 | } else if !a.config.InDocker && rev != "" { |
| 1631 | return fmt.Errorf("Not resetting git repo when working outside of a container.") |
| 1632 | } |
| 1633 | return nil |
| 1634 | } |
| 1635 | err := gitReset() |
| 1636 | a.mu.Unlock() |
| 1637 | if err != nil { |
| 1638 | a.pushToOutbox(a.config.Context, errorMessage(err)) |
| 1639 | } |
| 1640 | |
| 1641 | a.pushToOutbox(a.config.Context, AgentMessage{ |
| 1642 | Type: AgentMessageType, Content: "Conversation restarted.", |
| 1643 | }) |
| 1644 | if initialPrompt != "" { |
| 1645 | a.UserMessage(ctx, initialPrompt) |
| 1646 | } |
| 1647 | return nil |
| 1648 | } |
| 1649 | |
| 1650 | func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) { |
| 1651 | msg := `The user has requested a suggestion for a re-prompt. |
| 1652 | |
| 1653 | Given the current conversation thus far, suggest a re-prompt that would |
| 1654 | capture the instructions and feedback so far, as well as any |
| 1655 | research or other information that would be helpful in implementing |
| 1656 | the task. |
| 1657 | |
| 1658 | Reply with ONLY the reprompt text. |
| 1659 | ` |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1660 | userMessage := llm.UserStringMessage(msg) |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 1661 | // By doing this in a subconversation, the agent doesn't call tools (because |
| 1662 | // there aren't any), and there's not a concurrency risk with on-going other |
| 1663 | // outstanding conversations. |
| 1664 | convo := a.convo.SubConvoWithHistory() |
| 1665 | resp, err := convo.SendMessage(userMessage) |
| 1666 | if err != nil { |
| 1667 | a.pushToOutbox(ctx, errorMessage(err)) |
| 1668 | return "", err |
| 1669 | } |
| 1670 | textContent := collectTextContent(resp) |
| 1671 | return textContent, nil |
| 1672 | } |