| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1 | package loop |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| Josh Bleecher Snyder | dbe0230 | 2025-04-29 16:44:23 -0700 | [diff] [blame] | 5 | _ "embed" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 6 | "encoding/json" |
| 7 | "fmt" |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 8 | "io" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 9 | "log/slog" |
| 10 | "net/http" |
| 11 | "os" |
| 12 | "os/exec" |
| Pokey Rule | 7a11362 | 2025-05-12 10:58:45 +0100 | [diff] [blame] | 13 | "path/filepath" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 14 | "runtime/debug" |
| 15 | "slices" |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 16 | "strconv" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 17 | "strings" |
| 18 | "sync" |
| Josh Bleecher Snyder | 5cca56f | 2025-05-06 01:10:16 +0000 | [diff] [blame] | 19 | "text/template" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 20 | "time" |
| 21 | |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 22 | "sketch.dev/browser" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 23 | "sketch.dev/claudetool" |
| Autoformatter | 4962f15 | 2025-05-06 17:24:20 +0000 | [diff] [blame] | 24 | "sketch.dev/claudetool/browse" |
| Josh Bleecher Snyder | f4047bb | 2025-05-05 23:02:56 +0000 | [diff] [blame] | 25 | "sketch.dev/claudetool/codereview" |
| Josh Bleecher Snyder | a997be6 | 2025-05-07 22:52:46 +0000 | [diff] [blame] | 26 | "sketch.dev/claudetool/onstart" |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 27 | "sketch.dev/llm" |
| Philip Zeyliger | 72252cb | 2025-05-10 17:00:08 -0700 | [diff] [blame] | 28 | "sketch.dev/llm/ant" |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 29 | "sketch.dev/llm/conversation" |
| Philip Zeyliger | 194bfa8 | 2025-06-24 06:03:06 -0700 | [diff] [blame] | 30 | "sketch.dev/mcp" |
| Philip Zeyliger | c17ffe3 | 2025-06-05 19:49:13 -0700 | [diff] [blame] | 31 | "sketch.dev/skabandclient" |
| Philip Zeyliger | 5f26a34 | 2025-07-04 01:30:29 +0000 | [diff] [blame] | 32 | "tailscale.com/portlist" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 33 | ) |
| 34 | |
| 35 | const ( |
| 36 | userCancelMessage = "user requested agent to stop handling responses" |
| 37 | ) |
| 38 | |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 39 | type MessageIterator interface { |
| 40 | // Next blocks until the next message is available. It may |
| 41 | // return nil if the underlying iterator context is done. |
| 42 | Next() *AgentMessage |
| 43 | Close() |
| 44 | } |
| 45 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 46 | type CodingAgent interface { |
| 47 | // Init initializes an agent inside a docker container. |
| 48 | Init(AgentInit) error |
| 49 | |
| 50 | // Ready returns a channel closed after Init successfully called. |
| 51 | Ready() <-chan struct{} |
| 52 | |
| 53 | // URL reports the HTTP URL of this agent. |
| 54 | URL() string |
| 55 | |
| 56 | // UserMessage enqueues a message to the agent and returns immediately. |
| 57 | UserMessage(ctx context.Context, msg string) |
| 58 | |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 59 | // Returns an iterator that finishes when the context is done and |
| 60 | // starts with the given message index. |
| 61 | NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 62 | |
| Philip Zeyliger | eab12de | 2025-05-14 02:35:53 +0000 | [diff] [blame] | 63 | // Returns an iterator that notifies of state transitions until the context is done. |
| 64 | NewStateTransitionIterator(ctx context.Context) StateTransitionIterator |
| 65 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 66 | // Loop begins the agent loop returns only when ctx is cancelled. |
| 67 | Loop(ctx context.Context) |
| 68 | |
| Philip Zeyliger | be7802a | 2025-06-04 20:15:25 +0000 | [diff] [blame] | 69 | // BranchPrefix returns the configured branch prefix |
| 70 | BranchPrefix() string |
| 71 | |
| philip.zeyliger | 6d3de48 | 2025-06-10 19:38:14 -0700 | [diff] [blame] | 72 | // LinkToGitHub returns whether GitHub branch linking is enabled |
| 73 | LinkToGitHub() bool |
| 74 | |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 75 | CancelTurn(cause error) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 76 | |
| 77 | CancelToolUse(toolUseID string, cause error) error |
| 78 | |
| 79 | // Returns a subset of the agent's message history. |
| 80 | Messages(start int, end int) []AgentMessage |
| 81 | |
| 82 | // Returns the current number of messages in the history |
| 83 | MessageCount() int |
| 84 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 85 | TotalUsage() conversation.CumulativeUsage |
| 86 | OriginalBudget() conversation.Budget |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 87 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 88 | WorkingDir() string |
| Josh Bleecher Snyder | c5848f3 | 2025-05-28 18:50:58 +0000 | [diff] [blame] | 89 | RepoRoot() string |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 90 | |
| 91 | // Diff returns a unified diff of changes made since the agent was instantiated. |
| 92 | // If commit is non-nil, it shows the diff for just that specific commit. |
| 93 | Diff(commit *string) (string, error) |
| 94 | |
| Philip Zeyliger | 49edc92 | 2025-05-14 09:45:45 -0700 | [diff] [blame] | 95 | // SketchGitBase returns the commit that's the "base" for Sketch's work. It |
| 96 | // starts out as the commit where sketch started, but a user can move it if need |
| 97 | // be, for example in the case of a rebase. It is stored as a git tag. |
| 98 | SketchGitBase() string |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 99 | |
| Philip Zeyliger | d3ac112 | 2025-05-14 02:54:18 +0000 | [diff] [blame] | 100 | // SketchGitBase returns the symbolic name for the "base" for Sketch's work. |
| 101 | // (Typically, this is "sketch-base") |
| 102 | SketchGitBaseRef() string |
| 103 | |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 104 | // Slug returns the slug identifier for this session. |
| 105 | Slug() string |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 106 | |
| Josh Bleecher Snyder | 47b1936 | 2025-04-30 01:34:14 +0000 | [diff] [blame] | 107 | // BranchName returns the git branch name for the conversation. |
| 108 | BranchName() string |
| 109 | |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 110 | // IncrementRetryNumber increments the retry number for branch naming conflicts. |
| 111 | IncrementRetryNumber() |
| 112 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 113 | // OS returns the operating system of the client. |
| 114 | OS() string |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 115 | |
| Philip Zeyliger | c72fff5 | 2025-04-29 20:17:54 +0000 | [diff] [blame] | 116 | // SessionID returns the unique session identifier. |
| 117 | SessionID() string |
| 118 | |
| philip.zeyliger | 8773e68 | 2025-06-11 21:36:21 -0700 | [diff] [blame] | 119 | // SSHConnectionString returns the SSH connection string for the container. |
| 120 | SSHConnectionString() string |
| 121 | |
| Philip Zeyliger | 75bd37d | 2025-05-22 18:49:14 +0000 | [diff] [blame] | 122 | // DetectGitChanges checks for new git commits and pushes them if found |
| Philip Zeyliger | 9bca61e | 2025-05-22 12:40:06 -0700 | [diff] [blame] | 123 | DetectGitChanges(ctx context.Context) error |
| Philip Zeyliger | 75bd37d | 2025-05-22 18:49:14 +0000 | [diff] [blame] | 124 | |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 125 | // OutstandingLLMCallCount returns the number of outstanding LLM calls. |
| 126 | OutstandingLLMCallCount() int |
| 127 | |
| 128 | // OutstandingToolCalls returns the names of outstanding tool calls. |
| 129 | OutstandingToolCalls() []string |
| Philip Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 130 | OutsideOS() string |
| 131 | OutsideHostname() string |
| 132 | OutsideWorkingDir() string |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 133 | GitOrigin() string |
| Philip Zeyliger | 64f6046 | 2025-06-16 13:57:10 -0700 | [diff] [blame] | 134 | |
| banksean | cad67b0 | 2025-06-27 21:57:05 +0000 | [diff] [blame] | 135 | // GitUsername returns the git user name from the agent config. |
| 136 | GitUsername() string |
| 137 | |
| Philip Zeyliger | 254c49f | 2025-07-17 17:26:24 -0700 | [diff] [blame] | 138 | // PassthroughUpstream returns whether passthrough upstream is enabled. |
| 139 | PassthroughUpstream() bool |
| 140 | |
| Philip Zeyliger | 64f6046 | 2025-06-16 13:57:10 -0700 | [diff] [blame] | 141 | // DiffStats returns the number of lines added and removed from sketch-base to HEAD |
| 142 | DiffStats() (int, int) |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 143 | // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch. |
| 144 | OpenBrowser(url string) |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 145 | |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 146 | // IsInContainer returns true if the agent is running in a container |
| 147 | IsInContainer() bool |
| 148 | // FirstMessageIndex returns the index of the first message in the current conversation |
| 149 | FirstMessageIndex() int |
| Sean McCullough | d9d4581 | 2025-04-30 16:53:41 -0700 | [diff] [blame] | 150 | |
| 151 | CurrentStateName() string |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 152 | // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist |
| 153 | CurrentTodoContent() string |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 154 | |
| 155 | // CompactConversation compacts the current conversation by generating a summary |
| 156 | // and restarting the conversation with that summary as the initial context |
| 157 | CompactConversation(ctx context.Context) error |
| Philip Zeyliger | da623b5 | 2025-07-04 01:12:38 +0000 | [diff] [blame] | 158 | |
| Philip Zeyliger | 0113be5 | 2025-06-07 23:53:41 +0000 | [diff] [blame] | 159 | // SkabandAddr returns the skaband address if configured |
| 160 | SkabandAddr() string |
| Philip Zeyliger | 5f26a34 | 2025-07-04 01:30:29 +0000 | [diff] [blame] | 161 | |
| 162 | // GetPorts returns the cached list of open TCP ports |
| 163 | GetPorts() []portlist.Port |
| banksean | 5ab8fb8 | 2025-07-09 12:34:55 -0700 | [diff] [blame] | 164 | |
| 165 | // TokenContextWindow returns the TokenContextWindow size of the model the agent is using. |
| 166 | TokenContextWindow() int |
| Josh Bleecher Snyder | 4571fd6 | 2025-07-25 16:56:02 +0000 | [diff] [blame] | 167 | |
| 168 | // ModelName returns the name of the model the agent is using. |
| 169 | ModelName() string |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 170 | } |
| 171 | |
| 172 | type CodingAgentMessageType string |
| 173 | |
| 174 | const ( |
| 175 | UserMessageType CodingAgentMessageType = "user" |
| 176 | AgentMessageType CodingAgentMessageType = "agent" |
| 177 | ErrorMessageType CodingAgentMessageType = "error" |
| 178 | BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors |
| 179 | ToolUseMessageType CodingAgentMessageType = "tool" |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 180 | CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits |
| 181 | AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting |
| 182 | CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications |
| Philip Zeyliger | 5f26a34 | 2025-07-04 01:30:29 +0000 | [diff] [blame] | 183 | PortMessageType CodingAgentMessageType = "port" // for port monitoring events |
| Josh Bleecher Snyder | 3b44cc3 | 2025-07-22 02:28:14 +0000 | [diff] [blame] | 184 | SlugMessageType CodingAgentMessageType = "slug" // for slug updates |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 185 | |
| 186 | cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools." |
| 187 | ) |
| 188 | |
| 189 | type AgentMessage struct { |
| 190 | Type CodingAgentMessageType `json:"type"` |
| 191 | // EndOfTurn indicates that the AI is done working and is ready for the next user input. |
| 192 | EndOfTurn bool `json:"end_of_turn"` |
| 193 | |
| 194 | Content string `json:"content"` |
| 195 | ToolName string `json:"tool_name,omitempty"` |
| 196 | ToolInput string `json:"input,omitempty"` |
| 197 | ToolResult string `json:"tool_result,omitempty"` |
| 198 | ToolError bool `json:"tool_error,omitempty"` |
| 199 | ToolCallId string `json:"tool_call_id,omitempty"` |
| 200 | |
| 201 | // ToolCalls is a list of all tool calls requested in this message (name and input pairs) |
| 202 | ToolCalls []ToolCall `json:"tool_calls,omitempty"` |
| 203 | |
| Sean McCullough | d9f1337 | 2025-04-21 15:08:49 -0700 | [diff] [blame] | 204 | // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs) |
| 205 | ToolResponses []AgentMessage `json:"toolResponses,omitempty"` |
| 206 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 207 | // Commits is a list of git commits for a commit message |
| 208 | Commits []*GitCommit `json:"commits,omitempty"` |
| 209 | |
| 210 | Timestamp time.Time `json:"timestamp"` |
| 211 | ConversationID string `json:"conversation_id"` |
| 212 | ParentConversationID *string `json:"parent_conversation_id,omitempty"` |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 213 | Usage *llm.Usage `json:"usage,omitempty"` |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 214 | |
| 215 | // Message timing information |
| 216 | StartTime *time.Time `json:"start_time,omitempty"` |
| 217 | EndTime *time.Time `json:"end_time,omitempty"` |
| 218 | Elapsed *time.Duration `json:"elapsed,omitempty"` |
| 219 | |
| 220 | // Turn duration - the time taken for a complete agent turn |
| 221 | TurnDuration *time.Duration `json:"turnDuration,omitempty"` |
| 222 | |
| Josh Bleecher Snyder | 4d54493 | 2025-05-07 13:33:53 +0000 | [diff] [blame] | 223 | // HideOutput indicates that this message should not be rendered in the UI. |
| 224 | // This is useful for subconversations that generate output that shouldn't be shown to the user. |
| 225 | HideOutput bool `json:"hide_output,omitempty"` |
| 226 | |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 227 | // TodoContent contains the agent's todo file content when it has changed |
| 228 | TodoContent *string `json:"todo_content,omitempty"` |
| 229 | |
| Josh Bleecher Snyder | 3dd3e41 | 2025-07-22 20:32:03 -0700 | [diff] [blame] | 230 | // Display contains content to be displayed to the user, set by tools |
| 231 | Display any `json:"display,omitempty"` |
| 232 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 233 | Idx int `json:"idx"` |
| 234 | } |
| 235 | |
| Josh Bleecher Snyder | 4d54493 | 2025-05-07 13:33:53 +0000 | [diff] [blame] | 236 | // SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo. |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 237 | func (m *AgentMessage) SetConvo(convo *conversation.Convo) { |
| Josh Bleecher Snyder | 50a1d62 | 2025-04-29 09:59:03 -0700 | [diff] [blame] | 238 | if convo == nil { |
| 239 | m.ConversationID = "" |
| 240 | m.ParentConversationID = nil |
| 241 | return |
| 242 | } |
| 243 | m.ConversationID = convo.ID |
| Josh Bleecher Snyder | 4d54493 | 2025-05-07 13:33:53 +0000 | [diff] [blame] | 244 | m.HideOutput = convo.Hidden |
| Josh Bleecher Snyder | 50a1d62 | 2025-04-29 09:59:03 -0700 | [diff] [blame] | 245 | if convo.Parent != nil { |
| 246 | m.ParentConversationID = &convo.Parent.ID |
| 247 | } |
| 248 | } |
| 249 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 250 | // GitCommit represents a single git commit for a commit message |
| 251 | type GitCommit struct { |
| 252 | Hash string `json:"hash"` // Full commit hash |
| 253 | Subject string `json:"subject"` // Commit subject line |
| 254 | Body string `json:"body"` // Full commit message body |
| 255 | PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch |
| 256 | } |
| 257 | |
| 258 | // ToolCall represents a single tool call within an agent message |
| 259 | type ToolCall struct { |
| Sean McCullough | d9f1337 | 2025-04-21 15:08:49 -0700 | [diff] [blame] | 260 | Name string `json:"name"` |
| 261 | Input string `json:"input"` |
| 262 | ToolCallId string `json:"tool_call_id"` |
| 263 | ResultMessage *AgentMessage `json:"result_message,omitempty"` |
| 264 | Args string `json:"args,omitempty"` |
| 265 | Result string `json:"result,omitempty"` |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 266 | } |
| 267 | |
| 268 | func (a *AgentMessage) Attr() slog.Attr { |
| 269 | var attrs []any = []any{ |
| 270 | slog.String("type", string(a.Type)), |
| 271 | } |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 272 | attrs = append(attrs, slog.Int("idx", a.Idx)) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 273 | if a.EndOfTurn { |
| 274 | attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn)) |
| 275 | } |
| 276 | if a.Content != "" { |
| 277 | attrs = append(attrs, slog.String("content", a.Content)) |
| 278 | } |
| 279 | if a.ToolName != "" { |
| 280 | attrs = append(attrs, slog.String("tool_name", a.ToolName)) |
| 281 | } |
| 282 | if a.ToolInput != "" { |
| 283 | attrs = append(attrs, slog.String("tool_input", a.ToolInput)) |
| 284 | } |
| 285 | if a.Elapsed != nil { |
| 286 | attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds())) |
| 287 | } |
| 288 | if a.TurnDuration != nil { |
| 289 | attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds())) |
| 290 | } |
| Philip Zeyliger | 72252cb | 2025-05-10 17:00:08 -0700 | [diff] [blame] | 291 | if len(a.ToolResult) > 0 { |
| 292 | attrs = append(attrs, slog.Any("tool_result", a.ToolResult)) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 293 | } |
| 294 | if a.ToolError { |
| 295 | attrs = append(attrs, slog.Bool("tool_error", a.ToolError)) |
| 296 | } |
| 297 | if len(a.ToolCalls) > 0 { |
| 298 | toolCallAttrs := make([]any, 0, len(a.ToolCalls)) |
| 299 | for i, tc := range a.ToolCalls { |
| 300 | toolCallAttrs = append(toolCallAttrs, slog.Group( |
| 301 | fmt.Sprintf("tool_call_%d", i), |
| 302 | slog.String("name", tc.Name), |
| 303 | slog.String("input", tc.Input), |
| 304 | )) |
| 305 | } |
| 306 | attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...)) |
| 307 | } |
| 308 | if a.ConversationID != "" { |
| 309 | attrs = append(attrs, slog.String("convo_id", a.ConversationID)) |
| 310 | } |
| 311 | if a.ParentConversationID != nil { |
| 312 | attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID)) |
| 313 | } |
| 314 | if a.Usage != nil && !a.Usage.IsZero() { |
| 315 | attrs = append(attrs, a.Usage.Attr()) |
| 316 | } |
| 317 | // TODO: timestamp, convo ids, idx? |
| 318 | return slog.Group("agent_message", attrs...) |
| 319 | } |
| 320 | |
| 321 | func errorMessage(err error) AgentMessage { |
| 322 | // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach. |
| 323 | if os.Getenv(("DEBUG")) == "1" { |
| 324 | return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true} |
| 325 | } |
| 326 | |
| 327 | return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true} |
| 328 | } |
| 329 | |
| 330 | func budgetMessage(err error) AgentMessage { |
| 331 | return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true} |
| 332 | } |
| 333 | |
| 334 | // ConvoInterface defines the interface for conversation interactions |
| 335 | type ConvoInterface interface { |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 336 | CumulativeUsage() conversation.CumulativeUsage |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 337 | LastUsage() llm.Usage |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 338 | ResetBudget(conversation.Budget) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 339 | OverBudget() error |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 340 | SendMessage(message llm.Message) (*llm.Response, error) |
| 341 | SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error) |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 342 | GetID() string |
| Josh Bleecher Snyder | 64f2aa8 | 2025-05-14 18:31:05 +0000 | [diff] [blame] | 343 | ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error) |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 344 | ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 345 | CancelToolUse(toolUseID string, cause error) error |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 346 | SubConvoWithHistory() *conversation.Convo |
| Philip Zeyliger | 43a0bfc | 2025-07-14 14:54:27 -0700 | [diff] [blame] | 347 | DebugJSON() ([]byte, error) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 348 | } |
| 349 | |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 350 | // AgentGitState holds the state necessary for pushing to a remote git repo |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 351 | // when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/ |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 352 | // any time we notice we need to. |
| 353 | type AgentGitState struct { |
| 354 | mu sync.Mutex // protects following |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 355 | lastSketch string // hash of the last sketch branch that was pushed to the host |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 356 | gitRemoteAddr string // HTTP URL of the host git repo |
| Josh Bleecher Snyder | 664404e | 2025-06-04 21:56:42 +0000 | [diff] [blame] | 357 | upstream string // upstream branch for git work |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 358 | seenCommits map[string]bool // Track git commits we've already seen (by hash) |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 359 | slug string // Human-readable session identifier |
| 360 | retryNumber int // Number to append when branch conflicts occur |
| Philip Zeyliger | 64f6046 | 2025-06-16 13:57:10 -0700 | [diff] [blame] | 361 | linesAdded int // Lines added from sketch-base to HEAD |
| 362 | linesRemoved int // Lines removed from sketch-base to HEAD |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 363 | } |
| 364 | |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 365 | func (ags *AgentGitState) SetSlug(slug string) { |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 366 | ags.mu.Lock() |
| 367 | defer ags.mu.Unlock() |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 368 | if ags.slug != slug { |
| 369 | ags.retryNumber = 0 |
| 370 | } |
| 371 | ags.slug = slug |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 372 | } |
| 373 | |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 374 | func (ags *AgentGitState) Slug() string { |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 375 | ags.mu.Lock() |
| 376 | defer ags.mu.Unlock() |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 377 | return ags.slug |
| 378 | } |
| 379 | |
| 380 | func (ags *AgentGitState) IncrementRetryNumber() { |
| 381 | ags.mu.Lock() |
| 382 | defer ags.mu.Unlock() |
| 383 | ags.retryNumber++ |
| 384 | } |
| 385 | |
| Philip Zeyliger | 64f6046 | 2025-06-16 13:57:10 -0700 | [diff] [blame] | 386 | func (ags *AgentGitState) DiffStats() (int, int) { |
| 387 | ags.mu.Lock() |
| 388 | defer ags.mu.Unlock() |
| 389 | return ags.linesAdded, ags.linesRemoved |
| 390 | } |
| 391 | |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 392 | // HasSeenCommits returns true if any commits have been processed |
| 393 | func (ags *AgentGitState) HasSeenCommits() bool { |
| 394 | ags.mu.Lock() |
| 395 | defer ags.mu.Unlock() |
| 396 | return len(ags.seenCommits) > 0 |
| 397 | } |
| 398 | |
| 399 | func (ags *AgentGitState) RetryNumber() int { |
| 400 | ags.mu.Lock() |
| 401 | defer ags.mu.Unlock() |
| 402 | return ags.retryNumber |
| 403 | } |
| 404 | |
| 405 | func (ags *AgentGitState) BranchName(prefix string) string { |
| 406 | ags.mu.Lock() |
| 407 | defer ags.mu.Unlock() |
| 408 | return ags.branchNameLocked(prefix) |
| 409 | } |
| 410 | |
| 411 | func (ags *AgentGitState) branchNameLocked(prefix string) string { |
| 412 | if ags.slug == "" { |
| 413 | return "" |
| 414 | } |
| 415 | if ags.retryNumber == 0 { |
| 416 | return prefix + ags.slug |
| 417 | } |
| 418 | return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber) |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 419 | } |
| 420 | |
| Josh Bleecher Snyder | 664404e | 2025-06-04 21:56:42 +0000 | [diff] [blame] | 421 | func (ags *AgentGitState) Upstream() string { |
| 422 | ags.mu.Lock() |
| 423 | defer ags.mu.Unlock() |
| 424 | return ags.upstream |
| 425 | } |
| 426 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 427 | type Agent struct { |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 428 | convo ConvoInterface |
| 429 | config AgentConfig // config for this agent |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 430 | gitState AgentGitState |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 431 | workingDir string |
| 432 | repoRoot string // workingDir may be a subdir of repoRoot |
| 433 | url string |
| 434 | firstMessageIndex int // index of the first message in the current conversation |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 435 | outsideHTTP string // base address of the outside webserver (only when under docker) |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 436 | ready chan struct{} // closed when the agent is initialized (only when under docker) |
| Josh Bleecher Snyder | a997be6 | 2025-05-07 22:52:46 +0000 | [diff] [blame] | 437 | codebase *onstart.Codebase |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 438 | startedAt time.Time |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 439 | originalBudget conversation.Budget |
| Josh Bleecher Snyder | f4047bb | 2025-05-05 23:02:56 +0000 | [diff] [blame] | 440 | codereview *codereview.CodeReviewer |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 441 | // State machine to track agent state |
| 442 | stateMachine *StateMachine |
| Philip Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 443 | // Outside information |
| 444 | outsideHostname string |
| 445 | outsideOS string |
| 446 | outsideWorkingDir string |
| Philip Zeyliger | 194bfa8 | 2025-06-24 06:03:06 -0700 | [diff] [blame] | 447 | // MCP manager for handling MCP server connections |
| 448 | mcpManager *mcp.MCPManager |
| Philip Zeyliger | 5f26a34 | 2025-07-04 01:30:29 +0000 | [diff] [blame] | 449 | // Port monitor for tracking TCP ports |
| 450 | portMonitor *PortMonitor |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 451 | |
| 452 | // Time when the current turn started (reset at the beginning of InnerLoop) |
| 453 | startOfTurn time.Time |
| Josh Bleecher Snyder | 8a0de52 | 2025-07-24 19:29:07 +0000 | [diff] [blame^] | 454 | now func() time.Time // override-able, defaults to time.Now |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 455 | |
| 456 | // Inbox - for messages from the user to the agent. |
| 457 | // sent on by UserMessage |
| 458 | // . e.g. when user types into the chat textarea |
| 459 | // read from by GatherMessages |
| 460 | inbox chan string |
| 461 | |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 462 | // protects cancelTurn |
| 463 | cancelTurnMu sync.Mutex |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 464 | // cancels potentially long-running tool_use calls or chains of them |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 465 | cancelTurn context.CancelCauseFunc |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 466 | |
| 467 | // protects following |
| 468 | mu sync.Mutex |
| 469 | |
| 470 | // Stores all messages for this agent |
| 471 | history []AgentMessage |
| 472 | |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 473 | // Iterators add themselves here when they're ready to be notified of new messages. |
| 474 | subscribers []chan *AgentMessage |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 475 | |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 476 | // Track outstanding LLM call IDs |
| 477 | outstandingLLMCalls map[string]struct{} |
| 478 | |
| 479 | // Track outstanding tool calls by ID with their names |
| 480 | outstandingToolCalls map[string]string |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 481 | } |
| 482 | |
| banksean | 5ab8fb8 | 2025-07-09 12:34:55 -0700 | [diff] [blame] | 483 | // TokenContextWindow implements CodingAgent. |
| 484 | func (a *Agent) TokenContextWindow() int { |
| 485 | return a.config.Service.TokenContextWindow() |
| 486 | } |
| 487 | |
| Josh Bleecher Snyder | 4571fd6 | 2025-07-25 16:56:02 +0000 | [diff] [blame] | 488 | // ModelName returns the name of the model the agent is using. |
| 489 | func (a *Agent) ModelName() string { |
| 490 | return a.config.Model |
| 491 | } |
| 492 | |
| Philip Zeyliger | 43a0bfc | 2025-07-14 14:54:27 -0700 | [diff] [blame] | 493 | // GetConvo returns the conversation interface for debugging purposes. |
| 494 | func (a *Agent) GetConvo() ConvoInterface { |
| 495 | return a.convo |
| 496 | } |
| 497 | |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 498 | // NewIterator implements CodingAgent. |
| 499 | func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator { |
| 500 | a.mu.Lock() |
| 501 | defer a.mu.Unlock() |
| 502 | |
| 503 | return &MessageIteratorImpl{ |
| 504 | agent: a, |
| 505 | ctx: ctx, |
| 506 | nextMessageIdx: nextMessageIdx, |
| 507 | ch: make(chan *AgentMessage, 100), |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | type MessageIteratorImpl struct { |
| 512 | agent *Agent |
| 513 | ctx context.Context |
| 514 | nextMessageIdx int |
| 515 | ch chan *AgentMessage |
| 516 | subscribed bool |
| 517 | } |
| 518 | |
| 519 | func (m *MessageIteratorImpl) Close() { |
| 520 | m.agent.mu.Lock() |
| 521 | defer m.agent.mu.Unlock() |
| 522 | // Delete ourselves from the subscribers list |
| 523 | m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool { |
| 524 | return x == m.ch |
| 525 | }) |
| 526 | close(m.ch) |
| 527 | } |
| 528 | |
| 529 | func (m *MessageIteratorImpl) Next() *AgentMessage { |
| 530 | // We avoid subscription at creation to let ourselves catch up to "current state" |
| 531 | // before subscribing. |
| 532 | if !m.subscribed { |
| 533 | m.agent.mu.Lock() |
| 534 | if m.nextMessageIdx < len(m.agent.history) { |
| 535 | msg := &m.agent.history[m.nextMessageIdx] |
| 536 | m.nextMessageIdx++ |
| 537 | m.agent.mu.Unlock() |
| 538 | return msg |
| 539 | } |
| 540 | // The next message doesn't exist yet, so let's subscribe |
| 541 | m.agent.subscribers = append(m.agent.subscribers, m.ch) |
| 542 | m.subscribed = true |
| 543 | m.agent.mu.Unlock() |
| 544 | } |
| 545 | |
| 546 | for { |
| 547 | select { |
| 548 | case <-m.ctx.Done(): |
| 549 | m.agent.mu.Lock() |
| 550 | // Delete ourselves from the subscribers list |
| 551 | m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool { |
| 552 | return x == m.ch |
| 553 | }) |
| 554 | m.subscribed = false |
| 555 | m.agent.mu.Unlock() |
| 556 | return nil |
| 557 | case msg, ok := <-m.ch: |
| 558 | if !ok { |
| 559 | // Close may have been called |
| 560 | return nil |
| 561 | } |
| 562 | if msg.Idx == m.nextMessageIdx { |
| 563 | m.nextMessageIdx++ |
| 564 | return msg |
| 565 | } |
| 566 | slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content) |
| 567 | panic("out of order message") |
| 568 | } |
| 569 | } |
| 570 | } |
| 571 | |
| Sean McCullough | d9d4581 | 2025-04-30 16:53:41 -0700 | [diff] [blame] | 572 | // Assert that Agent satisfies the CodingAgent interface. |
| 573 | var _ CodingAgent = &Agent{} |
| 574 | |
| 575 | // StateName implements CodingAgent. |
| 576 | func (a *Agent) CurrentStateName() string { |
| 577 | if a.stateMachine == nil { |
| 578 | return "" |
| 579 | } |
| Josh Bleecher Snyder | ed17fdf | 2025-05-23 17:26:07 +0000 | [diff] [blame] | 580 | return a.stateMachine.CurrentState().String() |
| Sean McCullough | d9d4581 | 2025-04-30 16:53:41 -0700 | [diff] [blame] | 581 | } |
| 582 | |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 583 | // CurrentTodoContent returns the current todo list data as JSON. |
| 584 | // It returns an empty string if no todos exist. |
| 585 | func (a *Agent) CurrentTodoContent() string { |
| 586 | todoPath := claudetool.TodoFilePath(a.config.SessionID) |
| 587 | content, err := os.ReadFile(todoPath) |
| 588 | if err != nil { |
| 589 | return "" |
| 590 | } |
| 591 | return string(content) |
| 592 | } |
| 593 | |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 594 | // generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation |
| 595 | func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) { |
| 596 | msg := `You are being asked to create a comprehensive summary of our conversation so far. This summary will be used to restart our conversation with a shorter history while preserving all important context. |
| 597 | |
| 598 | IMPORTANT: Focus ONLY on the actual conversation with the user. Do NOT include any information from system prompts, tool descriptions, or general instructions. Only summarize what the user asked for and what we accomplished together. |
| 599 | |
| 600 | Please create a detailed summary that includes: |
| 601 | |
| 602 | 1. **User's Request**: What did the user originally ask me to do? What was their goal? |
| 603 | |
| 604 | 2. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc. |
| 605 | |
| 606 | 3. **Key Technical Decisions**: What important technical choices were made during our work and why? |
| 607 | |
| 608 | 4. **Current State**: What is the current state of the project? What files, tools, or systems are we working with? |
| 609 | |
| 610 | 5. **Next Steps**: What still needs to be done to complete the user's request? |
| 611 | |
| 612 | 6. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned. |
| 613 | |
| 614 | Focus on actionable information that would help me continue the user's work seamlessly. Ignore any general tool capabilities or system instructions - only include what's relevant to this specific user's project and goals. |
| 615 | |
| 616 | Reply with ONLY the summary content - no meta-commentary about creating the summary.` |
| 617 | |
| 618 | userMessage := llm.UserStringMessage(msg) |
| 619 | // Use a subconversation with history to get the summary |
| 620 | // TODO: We don't have any tools here, so we should have enough tokens |
| 621 | // to capture a summary, but we may need to modify the history (e.g., remove |
| 622 | // TODO data) to save on some tokens. |
| 623 | convo := a.convo.SubConvoWithHistory() |
| 624 | |
| 625 | // Modify the system prompt to provide context about the original task |
| 626 | originalSystemPrompt := convo.SystemPrompt |
| Josh Bleecher Snyder | 068f4bb | 2025-06-05 19:12:22 +0000 | [diff] [blame] | 627 | convo.SystemPrompt = `You are creating a conversation summary for context compaction. The original system prompt contained instructions about being a software engineer and architect for Sketch (an agentic coding environment), with various tools and capabilities for code analysis, file modification, git operations, browser automation, and project management. |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 628 | |
| 629 | Your task is to create a focused summary as requested below. Focus only on the actual user conversation and work accomplished, not the system capabilities or tool descriptions. |
| 630 | |
| Josh Bleecher Snyder | 068f4bb | 2025-06-05 19:12:22 +0000 | [diff] [blame] | 631 | Original context: You are working in a coding environment with full access to development tools.` |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 632 | |
| 633 | resp, err := convo.SendMessage(userMessage) |
| 634 | if err != nil { |
| 635 | a.pushToOutbox(ctx, errorMessage(err)) |
| 636 | return "", err |
| 637 | } |
| 638 | textContent := collectTextContent(resp) |
| 639 | |
| 640 | // Restore original system prompt (though this subconvo will be discarded) |
| 641 | convo.SystemPrompt = originalSystemPrompt |
| 642 | |
| 643 | return textContent, nil |
| 644 | } |
| 645 | |
| Philip Zeyliger | 9022ae0 | 2025-07-14 20:52:30 +0000 | [diff] [blame] | 646 | // dumpMessageHistoryToTmp dumps the agent's entire message history to /tmp as JSON |
| 647 | // and returns the filename |
| 648 | func (a *Agent) dumpMessageHistoryToTmp(ctx context.Context) (string, error) { |
| 649 | // Create a filename based on session ID and timestamp |
| 650 | timestamp := time.Now().Format("20060102-150405") |
| 651 | filename := fmt.Sprintf("/tmp/sketch-messages-%s-%s.json", a.config.SessionID, timestamp) |
| 652 | |
| 653 | // Marshal the entire message history to JSON |
| 654 | jsonData, err := json.MarshalIndent(a.history, "", " ") |
| 655 | if err != nil { |
| 656 | return "", fmt.Errorf("failed to marshal message history: %w", err) |
| 657 | } |
| 658 | |
| 659 | // Write to file |
| Autoformatter | 3ad8c8d | 2025-07-15 21:05:23 +0000 | [diff] [blame] | 660 | if err := os.WriteFile(filename, jsonData, 0o644); err != nil { |
| Philip Zeyliger | 9022ae0 | 2025-07-14 20:52:30 +0000 | [diff] [blame] | 661 | return "", fmt.Errorf("failed to write message history to %s: %w", filename, err) |
| 662 | } |
| 663 | |
| 664 | slog.InfoContext(ctx, "Dumped message history to file", "filename", filename, "message_count", len(a.history)) |
| 665 | return filename, nil |
| 666 | } |
| 667 | |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 668 | // CompactConversation compacts the current conversation by generating a summary |
| 669 | // and restarting the conversation with that summary as the initial context |
| 670 | func (a *Agent) CompactConversation(ctx context.Context) error { |
| Philip Zeyliger | 9022ae0 | 2025-07-14 20:52:30 +0000 | [diff] [blame] | 671 | // Dump the entire message history to /tmp as JSON before compacting |
| 672 | dumpFile, err := a.dumpMessageHistoryToTmp(ctx) |
| 673 | if err != nil { |
| 674 | slog.WarnContext(ctx, "Failed to dump message history to /tmp", "error", err) |
| 675 | // Continue with compaction even if dump fails |
| 676 | } |
| 677 | |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 678 | summary, err := a.generateConversationSummary(ctx) |
| 679 | if err != nil { |
| 680 | return fmt.Errorf("failed to generate conversation summary: %w", err) |
| 681 | } |
| 682 | |
| 683 | a.mu.Lock() |
| 684 | |
| 685 | // Get usage information before resetting conversation |
| 686 | lastUsage := a.convo.LastUsage() |
| 687 | contextWindow := a.config.Service.TokenContextWindow() |
| 688 | currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens |
| 689 | |
| philip.zeyliger | 882e7ea | 2025-06-20 14:31:16 +0000 | [diff] [blame] | 690 | // Preserve cumulative usage across compaction |
| 691 | cumulativeUsage := a.convo.CumulativeUsage() |
| 692 | |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 693 | // Reset conversation state but keep all other state (git, working dir, etc.) |
| 694 | a.firstMessageIndex = len(a.history) |
| philip.zeyliger | 882e7ea | 2025-06-20 14:31:16 +0000 | [diff] [blame] | 695 | a.convo = a.initConvoWithUsage(&cumulativeUsage) |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 696 | |
| 697 | a.mu.Unlock() |
| 698 | |
| 699 | // Create informative compaction message with token details |
| 700 | compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+ |
| 701 | "**Token Usage:** %d / %d tokens (%.1f%% of context window)", |
| 702 | currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100) |
| 703 | |
| 704 | a.pushToOutbox(ctx, AgentMessage{ |
| 705 | Type: CompactMessageType, |
| 706 | Content: compactionMsg, |
| 707 | }) |
| 708 | |
| Philip Zeyliger | 9022ae0 | 2025-07-14 20:52:30 +0000 | [diff] [blame] | 709 | // Create the message content with dump file information if available |
| 710 | var messageContent string |
| 711 | if dumpFile != "" { |
| 712 | messageContent = fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nThe complete message history has been dumped to %s for your reference if needed.\n\nPlease continue with the work based on this summary.", summary, dumpFile) |
| 713 | } else { |
| 714 | messageContent = fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nPlease continue with the work based on this summary.", summary) |
| 715 | } |
| 716 | |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 717 | a.pushToOutbox(ctx, AgentMessage{ |
| 718 | Type: UserMessageType, |
| Philip Zeyliger | 9022ae0 | 2025-07-14 20:52:30 +0000 | [diff] [blame] | 719 | Content: messageContent, |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 720 | }) |
| Philip Zeyliger | 9022ae0 | 2025-07-14 20:52:30 +0000 | [diff] [blame] | 721 | a.inbox <- messageContent |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 722 | |
| 723 | return nil |
| 724 | } |
| 725 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 726 | func (a *Agent) URL() string { return a.url } |
| 727 | |
| Philip Zeyliger | 5f26a34 | 2025-07-04 01:30:29 +0000 | [diff] [blame] | 728 | // GetPorts returns the cached list of open TCP ports. |
| 729 | func (a *Agent) GetPorts() []portlist.Port { |
| 730 | if a.portMonitor == nil { |
| 731 | return nil |
| 732 | } |
| 733 | return a.portMonitor.GetPorts() |
| 734 | } |
| 735 | |
| Josh Bleecher Snyder | 47b1936 | 2025-04-30 01:34:14 +0000 | [diff] [blame] | 736 | // BranchName returns the git branch name for the conversation. |
| 737 | func (a *Agent) BranchName() string { |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 738 | return a.gitState.BranchName(a.config.BranchPrefix) |
| 739 | } |
| 740 | |
| 741 | // Slug returns the slug identifier for this conversation. |
| 742 | func (a *Agent) Slug() string { |
| 743 | return a.gitState.Slug() |
| 744 | } |
| 745 | |
| 746 | // IncrementRetryNumber increments the retry number for branch naming conflicts |
| 747 | func (a *Agent) IncrementRetryNumber() { |
| 748 | a.gitState.IncrementRetryNumber() |
| Josh Bleecher Snyder | 47b1936 | 2025-04-30 01:34:14 +0000 | [diff] [blame] | 749 | } |
| 750 | |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 751 | // OutstandingLLMCallCount returns the number of outstanding LLM calls. |
| 752 | func (a *Agent) OutstandingLLMCallCount() int { |
| 753 | a.mu.Lock() |
| 754 | defer a.mu.Unlock() |
| 755 | return len(a.outstandingLLMCalls) |
| 756 | } |
| 757 | |
| 758 | // OutstandingToolCalls returns the names of outstanding tool calls. |
| 759 | func (a *Agent) OutstandingToolCalls() []string { |
| 760 | a.mu.Lock() |
| 761 | defer a.mu.Unlock() |
| 762 | |
| 763 | tools := make([]string, 0, len(a.outstandingToolCalls)) |
| 764 | for _, toolName := range a.outstandingToolCalls { |
| 765 | tools = append(tools, toolName) |
| 766 | } |
| 767 | return tools |
| 768 | } |
| 769 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 770 | // OS returns the operating system of the client. |
| 771 | func (a *Agent) OS() string { |
| 772 | return a.config.ClientGOOS |
| 773 | } |
| 774 | |
| Philip Zeyliger | c72fff5 | 2025-04-29 20:17:54 +0000 | [diff] [blame] | 775 | func (a *Agent) SessionID() string { |
| 776 | return a.config.SessionID |
| 777 | } |
| 778 | |
| philip.zeyliger | 8773e68 | 2025-06-11 21:36:21 -0700 | [diff] [blame] | 779 | // SSHConnectionString returns the SSH connection string for the container. |
| 780 | func (a *Agent) SSHConnectionString() string { |
| 781 | return a.config.SSHConnectionString |
| 782 | } |
| 783 | |
| Philip Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 784 | // OutsideOS returns the operating system of the outside system. |
| 785 | func (a *Agent) OutsideOS() string { |
| 786 | return a.outsideOS |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 787 | } |
| 788 | |
| Philip Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 789 | // OutsideHostname returns the hostname of the outside system. |
| 790 | func (a *Agent) OutsideHostname() string { |
| 791 | return a.outsideHostname |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 792 | } |
| 793 | |
| Philip Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 794 | // OutsideWorkingDir returns the working directory on the outside system. |
| 795 | func (a *Agent) OutsideWorkingDir() string { |
| 796 | return a.outsideWorkingDir |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 797 | } |
| 798 | |
| 799 | // GitOrigin returns the URL of the git remote 'origin' if it exists. |
| 800 | func (a *Agent) GitOrigin() string { |
| Josh Bleecher Snyder | 784d5bd | 2025-07-11 00:09:30 +0000 | [diff] [blame] | 801 | return a.config.OriginalGitOrigin |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 802 | } |
| 803 | |
| Philip Zeyliger | 254c49f | 2025-07-17 17:26:24 -0700 | [diff] [blame] | 804 | // PassthroughUpstream returns whether passthrough upstream is enabled. |
| 805 | func (a *Agent) PassthroughUpstream() bool { |
| 806 | return a.config.PassthroughUpstream |
| 807 | } |
| 808 | |
| banksean | cad67b0 | 2025-06-27 21:57:05 +0000 | [diff] [blame] | 809 | // GitUsername returns the git user name from the agent config. |
| 810 | func (a *Agent) GitUsername() string { |
| 811 | return a.config.GitUsername |
| 812 | } |
| 813 | |
| Philip Zeyliger | 64f6046 | 2025-06-16 13:57:10 -0700 | [diff] [blame] | 814 | // DiffStats returns the number of lines added and removed from sketch-base to HEAD |
| 815 | func (a *Agent) DiffStats() (int, int) { |
| 816 | return a.gitState.DiffStats() |
| 817 | } |
| 818 | |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 819 | func (a *Agent) OpenBrowser(url string) { |
| 820 | if !a.IsInContainer() { |
| 821 | browser.Open(url) |
| 822 | return |
| 823 | } |
| 824 | // We're in Docker, need to send a request to the Git server |
| 825 | // to signal that the outer process should open the browser. |
| Josh Bleecher Snyder | 9957046 | 2025-05-05 10:26:14 -0700 | [diff] [blame] | 826 | // We don't get to specify a URL, because we are untrusted. |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 827 | httpc := &http.Client{Timeout: 5 * time.Second} |
| Josh Bleecher Snyder | 9957046 | 2025-05-05 10:26:14 -0700 | [diff] [blame] | 828 | resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil) |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 829 | if err != nil { |
| Josh Bleecher Snyder | 9957046 | 2025-05-05 10:26:14 -0700 | [diff] [blame] | 830 | slog.Debug("browser launch request connection failed", "err", err) |
| Josh Bleecher Snyder | 3e2111b | 2025-04-30 17:53:28 +0000 | [diff] [blame] | 831 | return |
| 832 | } |
| 833 | defer resp.Body.Close() |
| 834 | if resp.StatusCode == http.StatusOK { |
| 835 | return |
| 836 | } |
| 837 | body, _ := io.ReadAll(resp.Body) |
| 838 | slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body)) |
| 839 | } |
| 840 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 841 | // CurrentState returns the current state of the agent's state machine. |
| 842 | func (a *Agent) CurrentState() State { |
| 843 | return a.stateMachine.CurrentState() |
| 844 | } |
| 845 | |
| Philip Zeyliger | 2c4db09 | 2025-04-28 16:57:50 -0700 | [diff] [blame] | 846 | func (a *Agent) IsInContainer() bool { |
| 847 | return a.config.InDocker |
| 848 | } |
| 849 | |
| 850 | func (a *Agent) FirstMessageIndex() int { |
| 851 | a.mu.Lock() |
| 852 | defer a.mu.Unlock() |
| 853 | return a.firstMessageIndex |
| 854 | } |
| 855 | |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 856 | // SetSlug sets a human-readable identifier for the conversation. |
| 857 | func (a *Agent) SetSlug(slug string) { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 858 | a.mu.Lock() |
| 859 | defer a.mu.Unlock() |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 860 | |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 861 | a.gitState.SetSlug(slug) |
| Josh Bleecher Snyder | 31785ae | 2025-05-06 01:50:58 +0000 | [diff] [blame] | 862 | convo, ok := a.convo.(*conversation.Convo) |
| 863 | if ok { |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 864 | convo.ExtraData["branch"] = a.BranchName() |
| Josh Bleecher Snyder | 31785ae | 2025-05-06 01:50:58 +0000 | [diff] [blame] | 865 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 866 | } |
| 867 | |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 868 | // 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] | 869 | 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] | 870 | // Track the tool call |
| 871 | a.mu.Lock() |
| 872 | a.outstandingToolCalls[id] = toolName |
| 873 | a.mu.Unlock() |
| 874 | } |
| 875 | |
| Philip Zeyliger | 72252cb | 2025-05-10 17:00:08 -0700 | [diff] [blame] | 876 | // contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types. |
| 877 | // If there's only one element in the array and it's a text type, it returns that text directly. |
| 878 | // It also processes nested ToolResult arrays recursively. |
| 879 | func contentToString(contents []llm.Content) string { |
| 880 | if len(contents) == 0 { |
| 881 | return "" |
| 882 | } |
| 883 | |
| 884 | // If there's only one element and it's a text type, return it directly |
| 885 | if len(contents) == 1 && contents[0].Type == llm.ContentTypeText { |
| 886 | return contents[0].Text |
| 887 | } |
| 888 | |
| 889 | // Otherwise, concatenate all text content |
| 890 | var result strings.Builder |
| 891 | for _, content := range contents { |
| 892 | if content.Type == llm.ContentTypeText { |
| 893 | result.WriteString(content.Text) |
| 894 | } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 { |
| 895 | // Recursively process nested tool results |
| 896 | result.WriteString(contentToString(content.ToolResult)) |
| 897 | } |
| 898 | } |
| 899 | |
| 900 | return result.String() |
| 901 | } |
| 902 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 903 | // OnToolResult implements ant.Listener. |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 904 | 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] | 905 | // Remove the tool call from outstanding calls |
| 906 | a.mu.Lock() |
| 907 | delete(a.outstandingToolCalls, toolID) |
| 908 | a.mu.Unlock() |
| 909 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 910 | m := AgentMessage{ |
| 911 | Type: ToolUseMessageType, |
| 912 | Content: content.Text, |
| Philip Zeyliger | 72252cb | 2025-05-10 17:00:08 -0700 | [diff] [blame] | 913 | ToolResult: contentToString(content.ToolResult), |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 914 | ToolError: content.ToolError, |
| 915 | ToolName: toolName, |
| 916 | ToolInput: string(toolInput), |
| 917 | ToolCallId: content.ToolUseID, |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 918 | StartTime: content.ToolUseStartTime, |
| 919 | EndTime: content.ToolUseEndTime, |
| Josh Bleecher Snyder | 3dd3e41 | 2025-07-22 20:32:03 -0700 | [diff] [blame] | 920 | Display: content.Display, |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 921 | } |
| 922 | |
| 923 | // 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] | 924 | if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil { |
| 925 | elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 926 | m.Elapsed = &elapsed |
| 927 | } |
| 928 | |
| Josh Bleecher Snyder | 50a1d62 | 2025-04-29 09:59:03 -0700 | [diff] [blame] | 929 | m.SetConvo(convo) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 930 | a.pushToOutbox(ctx, m) |
| 931 | } |
| 932 | |
| 933 | // OnRequest implements ant.Listener. |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 934 | 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] | 935 | a.mu.Lock() |
| 936 | defer a.mu.Unlock() |
| 937 | a.outstandingLLMCalls[id] = struct{}{} |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 938 | // We already get tool results from the above. We send user messages to the outbox in the agent loop. |
| 939 | } |
| 940 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 941 | // OnResponse implements conversation.Listener. Responses contain messages from the LLM |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 942 | // that need to be displayed (as well as tool calls that we send along when |
| 943 | // they're done). (It would be reasonable to also mention tool calls when they're |
| 944 | // started, but we don't do that yet.) |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 945 | 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] | 946 | // Remove the LLM call from outstanding calls |
| 947 | a.mu.Lock() |
| 948 | delete(a.outstandingLLMCalls, id) |
| 949 | a.mu.Unlock() |
| 950 | |
| Josh Bleecher Snyder | 50a1d62 | 2025-04-29 09:59:03 -0700 | [diff] [blame] | 951 | if resp == nil { |
| 952 | // LLM API call failed |
| 953 | m := AgentMessage{ |
| 954 | Type: ErrorMessageType, |
| 955 | Content: "API call failed, type 'continue' to try again", |
| 956 | } |
| 957 | m.SetConvo(convo) |
| 958 | a.pushToOutbox(ctx, m) |
| 959 | return |
| 960 | } |
| 961 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 962 | endOfTurn := false |
| Josh Bleecher Snyder | 4fcde4a | 2025-05-05 18:28:13 -0700 | [diff] [blame] | 963 | if convo.Parent == nil { // subconvos never end the turn |
| 964 | switch resp.StopReason { |
| 965 | case llm.StopReasonToolUse: |
| 966 | // Check whether any of the tool calls are for tools that should end the turn |
| 967 | ToolSearch: |
| 968 | for _, part := range resp.Content { |
| 969 | if part.Type != llm.ContentTypeToolUse { |
| 970 | continue |
| 971 | } |
| Sean McCullough | 021557a | 2025-05-05 23:20:53 +0000 | [diff] [blame] | 972 | // Find the tool by name |
| 973 | for _, tool := range convo.Tools { |
| Josh Bleecher Snyder | 4fcde4a | 2025-05-05 18:28:13 -0700 | [diff] [blame] | 974 | if tool.Name == part.ToolName { |
| 975 | endOfTurn = tool.EndsTurn |
| 976 | break ToolSearch |
| Sean McCullough | 021557a | 2025-05-05 23:20:53 +0000 | [diff] [blame] | 977 | } |
| 978 | } |
| Sean McCullough | 021557a | 2025-05-05 23:20:53 +0000 | [diff] [blame] | 979 | } |
| Josh Bleecher Snyder | 4fcde4a | 2025-05-05 18:28:13 -0700 | [diff] [blame] | 980 | default: |
| 981 | endOfTurn = true |
| Sean McCullough | 021557a | 2025-05-05 23:20:53 +0000 | [diff] [blame] | 982 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 983 | } |
| 984 | m := AgentMessage{ |
| 985 | Type: AgentMessageType, |
| 986 | Content: collectTextContent(resp), |
| 987 | EndOfTurn: endOfTurn, |
| 988 | Usage: &resp.Usage, |
| 989 | StartTime: resp.StartTime, |
| 990 | EndTime: resp.EndTime, |
| 991 | } |
| 992 | |
| 993 | // Extract any tool calls from the response |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 994 | if resp.StopReason == llm.StopReasonToolUse { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 995 | var toolCalls []ToolCall |
| 996 | for _, part := range resp.Content { |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 997 | if part.Type == llm.ContentTypeToolUse { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 998 | toolCalls = append(toolCalls, ToolCall{ |
| 999 | Name: part.ToolName, |
| 1000 | Input: string(part.ToolInput), |
| 1001 | ToolCallId: part.ID, |
| 1002 | }) |
| 1003 | } |
| 1004 | } |
| 1005 | m.ToolCalls = toolCalls |
| 1006 | } |
| 1007 | |
| 1008 | // Calculate the elapsed time if both start and end times are set |
| 1009 | if resp.StartTime != nil && resp.EndTime != nil { |
| 1010 | elapsed := resp.EndTime.Sub(*resp.StartTime) |
| 1011 | m.Elapsed = &elapsed |
| 1012 | } |
| 1013 | |
| Josh Bleecher Snyder | 50a1d62 | 2025-04-29 09:59:03 -0700 | [diff] [blame] | 1014 | m.SetConvo(convo) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1015 | a.pushToOutbox(ctx, m) |
| 1016 | } |
| 1017 | |
| 1018 | // WorkingDir implements CodingAgent. |
| 1019 | func (a *Agent) WorkingDir() string { |
| 1020 | return a.workingDir |
| 1021 | } |
| 1022 | |
| Josh Bleecher Snyder | c5848f3 | 2025-05-28 18:50:58 +0000 | [diff] [blame] | 1023 | // RepoRoot returns the git repository root directory. |
| 1024 | func (a *Agent) RepoRoot() string { |
| 1025 | return a.repoRoot |
| 1026 | } |
| 1027 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1028 | // MessageCount implements CodingAgent. |
| 1029 | func (a *Agent) MessageCount() int { |
| 1030 | a.mu.Lock() |
| 1031 | defer a.mu.Unlock() |
| 1032 | return len(a.history) |
| 1033 | } |
| 1034 | |
| 1035 | // Messages implements CodingAgent. |
| 1036 | func (a *Agent) Messages(start int, end int) []AgentMessage { |
| 1037 | a.mu.Lock() |
| 1038 | defer a.mu.Unlock() |
| 1039 | return slices.Clone(a.history[start:end]) |
| 1040 | } |
| 1041 | |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 1042 | // ShouldCompact checks if the conversation should be compacted based on token usage |
| 1043 | func (a *Agent) ShouldCompact() bool { |
| 1044 | // Get the threshold from environment variable, default to 0.94 (94%) |
| 1045 | // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens, |
| 1046 | // and a little bit of buffer.) |
| 1047 | thresholdRatio := 0.94 |
| 1048 | if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" { |
| 1049 | if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 { |
| 1050 | thresholdRatio = parsed |
| 1051 | } |
| 1052 | } |
| 1053 | |
| 1054 | // Get the most recent usage to check current context size |
| 1055 | lastUsage := a.convo.LastUsage() |
| 1056 | |
| 1057 | if lastUsage.InputTokens == 0 { |
| 1058 | // No API calls made yet |
| 1059 | return false |
| 1060 | } |
| 1061 | |
| 1062 | // Calculate the current context size from the last API call |
| 1063 | // This includes all tokens that were part of the input context: |
| 1064 | // - Input tokens (user messages, system prompt, conversation history) |
| 1065 | // - Cache read tokens (cached parts of the context) |
| 1066 | // - Cache creation tokens (new parts being cached) |
| 1067 | currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens |
| 1068 | |
| 1069 | // Get the service's token context window |
| 1070 | service := a.config.Service |
| 1071 | contextWindow := service.TokenContextWindow() |
| 1072 | |
| 1073 | // Calculate threshold |
| 1074 | threshold := uint64(float64(contextWindow) * thresholdRatio) |
| 1075 | |
| 1076 | // Check if we've exceeded the threshold |
| 1077 | return currentContextSize >= threshold |
| 1078 | } |
| 1079 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1080 | func (a *Agent) OriginalBudget() conversation.Budget { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1081 | return a.originalBudget |
| 1082 | } |
| 1083 | |
| Josh Bleecher Snyder | 664404e | 2025-06-04 21:56:42 +0000 | [diff] [blame] | 1084 | // Upstream returns the upstream branch for git work |
| 1085 | func (a *Agent) Upstream() string { |
| 1086 | return a.gitState.Upstream() |
| 1087 | } |
| 1088 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1089 | // AgentConfig contains configuration for creating a new Agent. |
| 1090 | type AgentConfig struct { |
| Josh Bleecher Snyder | b421a24 | 2025-05-29 23:22:55 +0000 | [diff] [blame] | 1091 | Context context.Context |
| 1092 | Service llm.Service |
| 1093 | Budget conversation.Budget |
| 1094 | GitUsername string |
| 1095 | GitEmail string |
| 1096 | SessionID string |
| 1097 | ClientGOOS string |
| 1098 | ClientGOARCH string |
| 1099 | InDocker bool |
| 1100 | OneShot bool |
| 1101 | WorkingDir string |
| Josh Bleecher Snyder | 4571fd6 | 2025-07-25 16:56:02 +0000 | [diff] [blame] | 1102 | // Model is the name of the LLM model being used |
| 1103 | Model string |
| Philip Zeyliger | 18532b2 | 2025-04-23 21:11:46 +0000 | [diff] [blame] | 1104 | // Outside information |
| 1105 | OutsideHostname string |
| 1106 | OutsideOS string |
| 1107 | OutsideWorkingDir string |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1108 | |
| 1109 | // Outtie's HTTP to, e.g., open a browser |
| 1110 | OutsideHTTP string |
| 1111 | // Outtie's Git server |
| 1112 | GitRemoteAddr string |
| Josh Bleecher Snyder | 784d5bd | 2025-07-11 00:09:30 +0000 | [diff] [blame] | 1113 | // Original git origin URL from host repository, if any |
| 1114 | OriginalGitOrigin string |
| Josh Bleecher Snyder | 664404e | 2025-06-04 21:56:42 +0000 | [diff] [blame] | 1115 | // Upstream branch for git work |
| 1116 | Upstream string |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1117 | // Commit to checkout from Outtie |
| 1118 | Commit string |
| Philip Zeyliger | be7802a | 2025-06-04 20:15:25 +0000 | [diff] [blame] | 1119 | // Prefix for git branches created by sketch |
| 1120 | BranchPrefix string |
| philip.zeyliger | 6d3de48 | 2025-06-10 19:38:14 -0700 | [diff] [blame] | 1121 | // LinkToGitHub enables GitHub branch linking in UI |
| 1122 | LinkToGitHub bool |
| philip.zeyliger | 8773e68 | 2025-06-11 21:36:21 -0700 | [diff] [blame] | 1123 | // SSH connection string for connecting to the container |
| 1124 | SSHConnectionString string |
| Philip Zeyliger | c17ffe3 | 2025-06-05 19:49:13 -0700 | [diff] [blame] | 1125 | // Skaband client for session history (optional) |
| 1126 | SkabandClient *skabandclient.SkabandClient |
| Philip Zeyliger | 194bfa8 | 2025-06-24 06:03:06 -0700 | [diff] [blame] | 1127 | // MCP server configurations |
| 1128 | MCPServers []string |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 1129 | // Timeout configuration for bash tool |
| 1130 | BashTimeouts *claudetool.Timeouts |
| Philip Zeyliger | 254c49f | 2025-07-17 17:26:24 -0700 | [diff] [blame] | 1131 | // PassthroughUpstream configures upstream remote for passthrough to innie |
| 1132 | PassthroughUpstream bool |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1133 | } |
| 1134 | |
| 1135 | // NewAgent creates a new Agent. |
| 1136 | // It is not usable until Init() is called. |
| 1137 | func NewAgent(config AgentConfig) *Agent { |
| Philip Zeyliger | be7802a | 2025-06-04 20:15:25 +0000 | [diff] [blame] | 1138 | // Set default branch prefix if not specified |
| 1139 | if config.BranchPrefix == "" { |
| 1140 | config.BranchPrefix = "sketch/" |
| 1141 | } |
| 1142 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1143 | agent := &Agent{ |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 1144 | config: config, |
| 1145 | ready: make(chan struct{}), |
| 1146 | inbox: make(chan string, 100), |
| 1147 | subscribers: make([]chan *AgentMessage, 0), |
| 1148 | startedAt: time.Now(), |
| 1149 | originalBudget: config.Budget, |
| 1150 | gitState: AgentGitState{ |
| 1151 | seenCommits: make(map[string]bool), |
| 1152 | gitRemoteAddr: config.GitRemoteAddr, |
| Josh Bleecher Snyder | 664404e | 2025-06-04 21:56:42 +0000 | [diff] [blame] | 1153 | upstream: config.Upstream, |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 1154 | }, |
| Philip Zeyliger | 99a9a02 | 2025-04-27 15:15:25 +0000 | [diff] [blame] | 1155 | outsideHostname: config.OutsideHostname, |
| 1156 | outsideOS: config.OutsideOS, |
| 1157 | outsideWorkingDir: config.OutsideWorkingDir, |
| 1158 | outstandingLLMCalls: make(map[string]struct{}), |
| 1159 | outstandingToolCalls: make(map[string]string), |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1160 | stateMachine: NewStateMachine(), |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1161 | workingDir: config.WorkingDir, |
| 1162 | outsideHTTP: config.OutsideHTTP, |
| Philip Zeyliger | da623b5 | 2025-07-04 01:12:38 +0000 | [diff] [blame] | 1163 | |
| 1164 | mcpManager: mcp.NewMCPManager(), |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1165 | } |
| Philip Zeyliger | 5f26a34 | 2025-07-04 01:30:29 +0000 | [diff] [blame] | 1166 | |
| 1167 | // Initialize port monitor with 5-second interval |
| 1168 | agent.portMonitor = NewPortMonitor(agent, 5*time.Second) |
| 1169 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1170 | return agent |
| 1171 | } |
| 1172 | |
| 1173 | type AgentInit struct { |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1174 | NoGit bool // only for testing |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1175 | |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1176 | InDocker bool |
| 1177 | HostAddr string |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1178 | } |
| 1179 | |
| 1180 | func (a *Agent) Init(ini AgentInit) error { |
| Josh Bleecher Snyder | 9c07e1d | 2025-04-28 19:25:37 -0700 | [diff] [blame] | 1181 | if a.convo != nil { |
| 1182 | return fmt.Errorf("Agent.Init: already initialized") |
| 1183 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1184 | ctx := a.config.Context |
| Philip Zeyliger | 716bfee | 2025-05-21 18:32:31 -0700 | [diff] [blame] | 1185 | slog.InfoContext(ctx, "agent initializing") |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1186 | |
| Josh Bleecher Snyder | 784d5bd | 2025-07-11 00:09:30 +0000 | [diff] [blame] | 1187 | // If a remote + commit was specified, clone it. |
| 1188 | if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" { |
| Philip Zeyliger | 254c49f | 2025-07-17 17:26:24 -0700 | [diff] [blame] | 1189 | if _, err := os.Stat("/app/.git"); err != nil { |
| Josh Bleecher Snyder | 369f262 | 2025-07-15 00:02:59 +0000 | [diff] [blame] | 1190 | slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit) |
| 1191 | // TODO: --reference-if-able instead? |
| 1192 | cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app") |
| 1193 | if out, err := cmd.CombinedOutput(); err != nil { |
| 1194 | return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err) |
| 1195 | } |
| Josh Bleecher Snyder | 784d5bd | 2025-07-11 00:09:30 +0000 | [diff] [blame] | 1196 | } |
| 1197 | } |
| 1198 | |
| 1199 | if a.workingDir != "" { |
| 1200 | err := os.Chdir(a.workingDir) |
| 1201 | if err != nil { |
| 1202 | return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err) |
| 1203 | } |
| 1204 | } |
| 1205 | |
| Philip Zeyliger | 2f0eb69 | 2025-06-04 09:53:42 -0700 | [diff] [blame] | 1206 | if !ini.NoGit { |
| Philip Zeyliger | accf37c | 2025-07-18 07:29:19 -0700 | [diff] [blame] | 1207 | if a.gitState.gitRemoteAddr != "" { |
| 1208 | if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil { |
| 1209 | return err |
| 1210 | } |
| Philip Zeyliger | 254c49f | 2025-07-17 17:26:24 -0700 | [diff] [blame] | 1211 | } |
| Philip Zeyliger | e1c8b7b | 2025-07-03 14:50:26 -0700 | [diff] [blame] | 1212 | |
| 1213 | // Configure git user settings |
| 1214 | if a.config.GitEmail != "" { |
| 1215 | cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail) |
| 1216 | cmd.Dir = a.workingDir |
| 1217 | if out, err := cmd.CombinedOutput(); err != nil { |
| 1218 | return fmt.Errorf("git config --global user.email: %s: %v", out, err) |
| 1219 | } |
| 1220 | } |
| 1221 | if a.config.GitUsername != "" { |
| 1222 | cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername) |
| 1223 | cmd.Dir = a.workingDir |
| 1224 | if out, err := cmd.CombinedOutput(); err != nil { |
| 1225 | return fmt.Errorf("git config --global user.name: %s: %v", out, err) |
| 1226 | } |
| 1227 | } |
| 1228 | // Configure git http.postBuffer |
| 1229 | cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000") |
| 1230 | cmd.Dir = a.workingDir |
| 1231 | if out, err := cmd.CombinedOutput(); err != nil { |
| 1232 | return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err) |
| 1233 | } |
| Philip Zeyliger | 254c49f | 2025-07-17 17:26:24 -0700 | [diff] [blame] | 1234 | |
| 1235 | // Configure passthrough upstream if enabled |
| 1236 | if a.config.PassthroughUpstream { |
| 1237 | if err := a.configurePassthroughUpstream(ctx); err != nil { |
| 1238 | return fmt.Errorf("failed to configure passthrough upstream: %w", err) |
| 1239 | } |
| 1240 | } |
| Philip Zeyliger | 2f0eb69 | 2025-06-04 09:53:42 -0700 | [diff] [blame] | 1241 | } |
| 1242 | |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 1243 | // If a commit was specified, we fetch and reset to it. |
| 1244 | if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" { |
| Josh Bleecher Snyder | 784d5bd | 2025-07-11 00:09:30 +0000 | [diff] [blame] | 1245 | slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit) |
| Philip Zeyliger | 716bfee | 2025-05-21 18:32:31 -0700 | [diff] [blame] | 1246 | |
| Josh Bleecher Snyder | 784d5bd | 2025-07-11 00:09:30 +0000 | [diff] [blame] | 1247 | cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin") |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1248 | cmd.Dir = a.workingDir |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1249 | if out, err := cmd.CombinedOutput(); err != nil { |
| 1250 | return fmt.Errorf("git fetch: %s: %w", out, err) |
| 1251 | } |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 1252 | // The -B resets the branch if it already exists (or creates it if it doesn't) |
| 1253 | cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit) |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1254 | cmd.Dir = a.workingDir |
| Pokey Rule | 7a11362 | 2025-05-12 10:58:45 +0100 | [diff] [blame] | 1255 | if checkoutOut, err := cmd.CombinedOutput(); err != nil { |
| 1256 | // Remove git hooks if they exist and retry |
| 1257 | // Only try removing hooks if we haven't already removed them during fetch |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1258 | hookPath := filepath.Join(a.workingDir, ".git", "hooks") |
| Pokey Rule | 7a11362 | 2025-05-12 10:58:45 +0100 | [diff] [blame] | 1259 | if _, statErr := os.Stat(hookPath); statErr == nil { |
| 1260 | slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying", |
| 1261 | slog.String("error", err.Error()), |
| 1262 | slog.String("output", string(checkoutOut))) |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1263 | if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil { |
| Pokey Rule | 7a11362 | 2025-05-12 10:58:45 +0100 | [diff] [blame] | 1264 | slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error())) |
| 1265 | } |
| 1266 | |
| 1267 | // Retry the checkout operation |
| Philip Zeyliger | 1417b69 | 2025-06-12 11:07:04 -0700 | [diff] [blame] | 1268 | cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit) |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1269 | cmd.Dir = a.workingDir |
| Pokey Rule | 7a11362 | 2025-05-12 10:58:45 +0100 | [diff] [blame] | 1270 | if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil { |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1271 | return fmt.Errorf("git checkout %s failed even after removing hooks: %s: %w", a.config.Commit, retryOut, retryErr) |
| Pokey Rule | 7a11362 | 2025-05-12 10:58:45 +0100 | [diff] [blame] | 1272 | } |
| 1273 | } else { |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 1274 | return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err) |
| Pokey Rule | 7a11362 | 2025-05-12 10:58:45 +0100 | [diff] [blame] | 1275 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1276 | } |
| Philip Zeyliger | 4c1cea8 | 2025-06-09 14:16:52 -0700 | [diff] [blame] | 1277 | } else if a.IsInContainer() { |
| 1278 | // If we're not running in a container, we don't switch branches (nor push branches back and forth). |
| 1279 | slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit)) |
| 1280 | cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip") |
| 1281 | cmd.Dir = a.workingDir |
| 1282 | if checkoutOut, err := cmd.CombinedOutput(); err != nil { |
| 1283 | return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err) |
| 1284 | } |
| 1285 | } else { |
| 1286 | slog.InfoContext(ctx, "Not checking out any branch") |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1287 | } |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1288 | |
| 1289 | if ini.HostAddr != "" { |
| 1290 | a.url = "http://" + ini.HostAddr |
| 1291 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1292 | |
| 1293 | if !ini.NoGit { |
| 1294 | repoRoot, err := repoRoot(ctx, a.workingDir) |
| 1295 | if err != nil { |
| 1296 | return fmt.Errorf("repoRoot: %w", err) |
| 1297 | } |
| 1298 | a.repoRoot = repoRoot |
| 1299 | |
| Josh Bleecher Snyder | fea9e27 | 2025-06-02 21:21:59 +0000 | [diff] [blame] | 1300 | if a.IsInContainer() { |
| Philip Zeyliger | f75ba2c | 2025-06-02 17:02:51 -0700 | [diff] [blame] | 1301 | if err := setupGitHooks(a.repoRoot); err != nil { |
| 1302 | slog.WarnContext(ctx, "failed to set up git hooks", "err", err) |
| 1303 | } |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 1304 | } |
| 1305 | |
| philz | 2461320 | 2025-07-15 20:56:21 -0700 | [diff] [blame] | 1306 | // Check if we have any commits, and if not, create an empty initial commit |
| 1307 | cmd := exec.CommandContext(ctx, "git", "rev-list", "--all", "--count") |
| 1308 | cmd.Dir = repoRoot |
| 1309 | countOut, err := cmd.CombinedOutput() |
| 1310 | if err != nil { |
| 1311 | return fmt.Errorf("git rev-list --all --count: %s: %w", countOut, err) |
| 1312 | } |
| 1313 | commitCount := strings.TrimSpace(string(countOut)) |
| 1314 | if commitCount == "0" { |
| 1315 | slog.Info("No commits found, creating empty initial commit") |
| 1316 | cmd = exec.CommandContext(ctx, "git", "commit", "--allow-empty", "-m", "Initial empty commit") |
| 1317 | cmd.Dir = repoRoot |
| 1318 | if commitOut, err := cmd.CombinedOutput(); err != nil { |
| 1319 | return fmt.Errorf("git commit --allow-empty: %s: %w", commitOut, err) |
| 1320 | } |
| 1321 | } |
| 1322 | |
| 1323 | cmd = exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD") |
| Philip Zeyliger | 49edc92 | 2025-05-14 09:45:45 -0700 | [diff] [blame] | 1324 | cmd.Dir = repoRoot |
| 1325 | if out, err := cmd.CombinedOutput(); err != nil { |
| 1326 | return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err) |
| 1327 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1328 | |
| Josh Bleecher Snyder | 0e5b8c6 | 2025-05-14 20:58:20 +0000 | [diff] [blame] | 1329 | slog.Info("running codebase analysis") |
| 1330 | codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot) |
| 1331 | if err != nil { |
| 1332 | slog.Warn("failed to analyze codebase", "error", err) |
| Josh Bleecher Snyder | a997be6 | 2025-05-07 22:52:46 +0000 | [diff] [blame] | 1333 | } |
| Josh Bleecher Snyder | 0e5b8c6 | 2025-05-14 20:58:20 +0000 | [diff] [blame] | 1334 | a.codebase = codebase |
| Josh Bleecher Snyder | a997be6 | 2025-05-07 22:52:46 +0000 | [diff] [blame] | 1335 | |
| Josh Bleecher Snyder | 9daa518 | 2025-05-16 18:34:00 +0000 | [diff] [blame] | 1336 | codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef()) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1337 | if err != nil { |
| Josh Bleecher Snyder | f4047bb | 2025-05-05 23:02:56 +0000 | [diff] [blame] | 1338 | return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1339 | } |
| 1340 | a.codereview = codereview |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 1341 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1342 | } |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 1343 | a.gitState.lastSketch = a.SketchGitBase() |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1344 | a.convo = a.initConvo() |
| 1345 | close(a.ready) |
| 1346 | return nil |
| 1347 | } |
| 1348 | |
| Josh Bleecher Snyder | dbe0230 | 2025-04-29 16:44:23 -0700 | [diff] [blame] | 1349 | //go:embed agent_system_prompt.txt |
| 1350 | var agentSystemPrompt string |
| 1351 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1352 | // initConvo initializes the conversation. |
| 1353 | // It must not be called until all agent fields are initialized, |
| 1354 | // particularly workingDir and git. |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1355 | func (a *Agent) initConvo() *conversation.Convo { |
| philip.zeyliger | 882e7ea | 2025-06-20 14:31:16 +0000 | [diff] [blame] | 1356 | return a.initConvoWithUsage(nil) |
| 1357 | } |
| 1358 | |
| 1359 | // initConvoWithUsage initializes the conversation with optional preserved usage. |
| 1360 | func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1361 | ctx := a.config.Context |
| philip.zeyliger | 882e7ea | 2025-06-20 14:31:16 +0000 | [diff] [blame] | 1362 | convo := conversation.New(ctx, a.config.Service, usage) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1363 | convo.PromptCaching = true |
| 1364 | convo.Budget = a.config.Budget |
| Josh Bleecher Snyder | 5cca56f | 2025-05-06 01:10:16 +0000 | [diff] [blame] | 1365 | convo.SystemPrompt = a.renderSystemPrompt() |
| Josh Bleecher Snyder | 31785ae | 2025-05-06 01:50:58 +0000 | [diff] [blame] | 1366 | convo.ExtraData = map[string]any{"session_id": a.config.SessionID} |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1367 | |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 1368 | bashTool := &claudetool.BashTool{ |
| Josh Bleecher Snyder | 17b2fd9 | 2025-07-09 22:47:13 +0000 | [diff] [blame] | 1369 | EnableJITInstall: claudetool.EnableBashToolJITInstall, |
| 1370 | Timeouts: a.config.BashTimeouts, |
| 1371 | } |
| Josh Bleecher Snyder | d499fd6 | 2025-04-30 01:31:29 +0000 | [diff] [blame] | 1372 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1373 | // Register all tools with the conversation |
| 1374 | // When adding, removing, or modifying tools here, double-check that the termui tool display |
| 1375 | // template in termui/termui.go has pretty-printing support for all tools. |
| Philip Zeyliger | 33d282f | 2025-05-03 04:01:54 +0000 | [diff] [blame] | 1376 | |
| 1377 | var browserTools []*llm.Tool |
| Philip Zeyliger | 80b488d | 2025-05-10 18:21:54 -0700 | [diff] [blame] | 1378 | _, supportsScreenshots := a.config.Service.(*ant.Service) |
| 1379 | var bTools []*llm.Tool |
| 1380 | var browserCleanup func() |
| 1381 | |
| 1382 | bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots) |
| 1383 | // Add cleanup function to context cancel |
| 1384 | go func() { |
| 1385 | <-a.config.Context.Done() |
| 1386 | browserCleanup() |
| 1387 | }() |
| 1388 | browserTools = bTools |
| Philip Zeyliger | 33d282f | 2025-05-03 04:01:54 +0000 | [diff] [blame] | 1389 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1390 | convo.Tools = []*llm.Tool{ |
| Josh Bleecher Snyder | d64bc91 | 2025-07-24 11:42:33 -0700 | [diff] [blame] | 1391 | bashTool.Tool(), |
| 1392 | claudetool.Keyword, |
| 1393 | claudetool.Patch(a.patchCallback), |
| 1394 | claudetool.Think, |
| 1395 | claudetool.TodoRead, |
| 1396 | claudetool.TodoWrite, |
| 1397 | makeDoneTool(a.codereview), |
| 1398 | a.codereview.Tool(), |
| 1399 | claudetool.AboutSketch, |
| Josh Bleecher Snyder | 31785ae | 2025-05-06 01:50:58 +0000 | [diff] [blame] | 1400 | } |
| Philip Zeyliger | 33d282f | 2025-05-03 04:01:54 +0000 | [diff] [blame] | 1401 | convo.Tools = append(convo.Tools, browserTools...) |
| Philip Zeyliger | c17ffe3 | 2025-06-05 19:49:13 -0700 | [diff] [blame] | 1402 | |
| Philip Zeyliger | 194bfa8 | 2025-06-24 06:03:06 -0700 | [diff] [blame] | 1403 | // Add MCP tools if configured |
| 1404 | if len(a.config.MCPServers) > 0 { |
| Philip Zeyliger | 4201bde | 2025-06-27 17:22:43 -0700 | [diff] [blame] | 1405 | |
| Philip Zeyliger | 194bfa8 | 2025-06-24 06:03:06 -0700 | [diff] [blame] | 1406 | slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers)) |
| Philip Zeyliger | 4201bde | 2025-06-27 17:22:43 -0700 | [diff] [blame] | 1407 | serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers) |
| 1408 | |
| 1409 | // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values. |
| 1410 | for i := range serverConfigs { |
| 1411 | if serverConfigs[i].Headers != nil { |
| 1412 | for key, value := range serverConfigs[i].Headers { |
| Philip Zeyliger | f2814ea | 2025-06-30 10:16:50 -0700 | [diff] [blame] | 1413 | // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO") |
| 1414 | if strings.HasPrefix(value, "env:") { |
| 1415 | serverConfigs[i].Headers[key] = os.Getenv(value[4:]) |
| Philip Zeyliger | 4201bde | 2025-06-27 17:22:43 -0700 | [diff] [blame] | 1416 | } |
| 1417 | } |
| 1418 | } |
| 1419 | } |
| Philip Zeyliger | c540df7 | 2025-07-25 09:21:56 -0700 | [diff] [blame] | 1420 | mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, mcp.DefaultMCPConnectionTimeout, parseErrors) |
| Philip Zeyliger | 194bfa8 | 2025-06-24 06:03:06 -0700 | [diff] [blame] | 1421 | |
| 1422 | if len(mcpErrors) > 0 { |
| 1423 | for _, err := range mcpErrors { |
| 1424 | slog.ErrorContext(ctx, "MCP connection error", "error", err) |
| 1425 | // Send agent message about MCP connection failures |
| 1426 | a.pushToOutbox(ctx, AgentMessage{ |
| 1427 | Type: ErrorMessageType, |
| 1428 | Content: fmt.Sprintf("MCP server connection failed: %v", err), |
| 1429 | }) |
| 1430 | } |
| 1431 | } |
| 1432 | |
| 1433 | if len(mcpConnections) > 0 { |
| 1434 | // Add tools from all successful connections |
| 1435 | totalTools := 0 |
| 1436 | for _, connection := range mcpConnections { |
| 1437 | convo.Tools = append(convo.Tools, connection.Tools...) |
| 1438 | totalTools += len(connection.Tools) |
| 1439 | // Log tools per server using structured data |
| 1440 | slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames) |
| 1441 | } |
| 1442 | slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools) |
| 1443 | } else { |
| 1444 | slog.InfoContext(ctx, "No MCP tools available after connection attempts") |
| 1445 | } |
| 1446 | } |
| 1447 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1448 | convo.Listener = a |
| 1449 | return convo |
| 1450 | } |
| 1451 | |
| Josh Bleecher Snyder | fff269b | 2025-04-30 01:49:39 +0000 | [diff] [blame] | 1452 | // branchExists reports whether branchName exists, either locally or in well-known remotes. |
| 1453 | func branchExists(dir, branchName string) bool { |
| 1454 | refs := []string{ |
| 1455 | "refs/heads/", |
| 1456 | "refs/remotes/origin/", |
| Josh Bleecher Snyder | fff269b | 2025-04-30 01:49:39 +0000 | [diff] [blame] | 1457 | } |
| 1458 | for _, ref := range refs { |
| 1459 | cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName) |
| 1460 | cmd.Dir = dir |
| 1461 | if cmd.Run() == nil { // exit code 0 means branch exists |
| 1462 | return true |
| 1463 | } |
| 1464 | } |
| 1465 | return false |
| 1466 | } |
| 1467 | |
| Josh Bleecher Snyder | 3b44cc3 | 2025-07-22 02:28:14 +0000 | [diff] [blame] | 1468 | func soleText(contents []llm.Content) (string, error) { |
| 1469 | if len(contents) != 1 { |
| 1470 | return "", fmt.Errorf("multiple contents %v", contents) |
| 1471 | } |
| 1472 | content := contents[0] |
| 1473 | if content.Type != llm.ContentTypeText || content.Text == "" { |
| 1474 | return "", fmt.Errorf("bad content %v", content) |
| 1475 | } |
| 1476 | return strings.TrimSpace(content.Text), nil |
| 1477 | } |
| 1478 | |
| 1479 | // autoGenerateSlug automatically generates a slug based on the first user input |
| 1480 | func (a *Agent) autoGenerateSlug(ctx context.Context, userContents []llm.Content) error { |
| 1481 | userText, err := soleText(userContents) |
| 1482 | if err != nil { |
| 1483 | return err |
| 1484 | } |
| 1485 | if userText == "" { |
| 1486 | return fmt.Errorf("set-slug: empty text content") |
| 1487 | } |
| 1488 | |
| 1489 | // Create a subconversation without history for slug generation |
| 1490 | convo, ok := a.convo.(*conversation.Convo) |
| 1491 | if !ok { |
| 1492 | // In test environments, the conversation might be a mock interface |
| 1493 | // Skip slug generation in this case |
| 1494 | return fmt.Errorf("set-slug: can't make a subconvo (mock convo?)") |
| 1495 | } |
| 1496 | |
| 1497 | // Loop until we find an acceptable slug |
| 1498 | var unavailableSlugs []string |
| 1499 | for { |
| 1500 | if len(unavailableSlugs) > 10 { |
| 1501 | // sanity check to prevent infinite loops |
| 1502 | return fmt.Errorf("set-slug: failed to construct a new slug after %d attempts", len(unavailableSlugs)) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1503 | } |
| Josh Bleecher Snyder | 3b44cc3 | 2025-07-22 02:28:14 +0000 | [diff] [blame] | 1504 | subConvo := convo.SubConvo() |
| 1505 | subConvo.Hidden = true |
| 1506 | |
| 1507 | // Prompt for slug generation |
| 1508 | prompt := `You are a slug generator for Sketch, an agentic coding environment. |
| 1509 | The user's prompt will be in <user-prompt> tags. Any unavailable slugs will be listed in <unavailable-slug> tags. |
| 1510 | Generate a 2-3 word alphanumeric hyphenated slug in imperative tense that captures the essence of their coding task. |
| 1511 | Respond with only the slug.` |
| 1512 | |
| 1513 | buf := new(strings.Builder) |
| 1514 | buf.WriteString("<slug-request>") |
| 1515 | if len(unavailableSlugs) > 0 { |
| 1516 | buf.WriteString("<unavailable-slugs>") |
| 1517 | } |
| 1518 | for _, x := range unavailableSlugs { |
| 1519 | buf.WriteString("<unavailable-slug>") |
| 1520 | buf.WriteString(x) |
| 1521 | buf.WriteString("</unavailable-slug>") |
| 1522 | } |
| 1523 | if len(unavailableSlugs) > 0 { |
| 1524 | buf.WriteString("</unavailable-slugs>") |
| 1525 | } |
| 1526 | buf.WriteString("<user-prompt>") |
| 1527 | buf.WriteString(userText) |
| 1528 | buf.WriteString("</user-prompt>") |
| 1529 | buf.WriteString("</slug-request>") |
| 1530 | |
| 1531 | fullPrompt := prompt + "\n" + buf.String() |
| 1532 | userMessage := llm.UserStringMessage(fullPrompt) |
| 1533 | |
| 1534 | resp, err := subConvo.SendMessage(userMessage) |
| 1535 | if err != nil { |
| 1536 | return fmt.Errorf("failed to generate slug: %w", err) |
| 1537 | } |
| 1538 | |
| 1539 | // Extract the slug from the response |
| 1540 | slugText, err := soleText(resp.Content) |
| 1541 | if err != nil { |
| 1542 | return err |
| 1543 | } |
| 1544 | if slugText == "" { |
| 1545 | return fmt.Errorf("empty slug generated") |
| 1546 | } |
| 1547 | |
| 1548 | // Clean and validate the slug |
| 1549 | slug := cleanSlugName(slugText) |
| 1550 | if slug == "" { |
| 1551 | return fmt.Errorf("slug could not be cleaned: %q", slugText) |
| 1552 | } |
| 1553 | |
| 1554 | // Check if branch already exists using the same logic as the original set-slug tool |
| 1555 | a.SetSlug(slug) // Set slug first so BranchName() works correctly |
| 1556 | if branchExists(a.workingDir, a.BranchName()) { |
| 1557 | // try again |
| 1558 | unavailableSlugs = append(unavailableSlugs, slug) |
| 1559 | continue |
| 1560 | } |
| 1561 | |
| 1562 | // Success! Slug is available and already set |
| 1563 | return nil |
| Josh Bleecher Snyder | a2a3150 | 2025-05-07 12:37:18 +0000 | [diff] [blame] | 1564 | } |
| Josh Bleecher Snyder | a2a3150 | 2025-05-07 12:37:18 +0000 | [diff] [blame] | 1565 | } |
| 1566 | |
| Josh Bleecher Snyder | 6534c7a | 2025-07-01 01:48:52 +0000 | [diff] [blame] | 1567 | // patchCallback is the agent's patch tool callback. |
| 1568 | // It warms the codereview cache in the background. |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 1569 | func (a *Agent) patchCallback(input claudetool.PatchInput, output llm.ToolOut) llm.ToolOut { |
| Josh Bleecher Snyder | 6534c7a | 2025-07-01 01:48:52 +0000 | [diff] [blame] | 1570 | if a.codereview != nil { |
| 1571 | a.codereview.WarmTestCache(input.Path) |
| 1572 | } |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 1573 | return output |
| Josh Bleecher Snyder | 6534c7a | 2025-07-01 01:48:52 +0000 | [diff] [blame] | 1574 | } |
| 1575 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1576 | func (a *Agent) Ready() <-chan struct{} { |
| 1577 | return a.ready |
| 1578 | } |
| 1579 | |
| Philip Zeyliger | be7802a | 2025-06-04 20:15:25 +0000 | [diff] [blame] | 1580 | // BranchPrefix returns the configured branch prefix |
| 1581 | func (a *Agent) BranchPrefix() string { |
| 1582 | return a.config.BranchPrefix |
| 1583 | } |
| 1584 | |
| philip.zeyliger | 6d3de48 | 2025-06-10 19:38:14 -0700 | [diff] [blame] | 1585 | // LinkToGitHub returns whether GitHub branch linking is enabled |
| 1586 | func (a *Agent) LinkToGitHub() bool { |
| 1587 | return a.config.LinkToGitHub |
| 1588 | } |
| 1589 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1590 | func (a *Agent) UserMessage(ctx context.Context, msg string) { |
| 1591 | a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg}) |
| 1592 | a.inbox <- msg |
| 1593 | } |
| 1594 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1595 | func (a *Agent) CancelToolUse(toolUseID string, cause error) error { |
| 1596 | return a.convo.CancelToolUse(toolUseID, cause) |
| 1597 | } |
| 1598 | |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 1599 | func (a *Agent) CancelTurn(cause error) { |
| 1600 | a.cancelTurnMu.Lock() |
| 1601 | defer a.cancelTurnMu.Unlock() |
| 1602 | if a.cancelTurn != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1603 | // Force state transition to cancelled state |
| 1604 | ctx := a.config.Context |
| 1605 | a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error()) |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 1606 | a.cancelTurn(cause) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1607 | } |
| 1608 | } |
| 1609 | |
| 1610 | func (a *Agent) Loop(ctxOuter context.Context) { |
| Philip Zeyliger | 5f26a34 | 2025-07-04 01:30:29 +0000 | [diff] [blame] | 1611 | // Start port monitoring |
| 1612 | if a.portMonitor != nil && a.IsInContainer() { |
| 1613 | if err := a.portMonitor.Start(ctxOuter); err != nil { |
| 1614 | slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err) |
| 1615 | } else { |
| 1616 | slog.InfoContext(ctxOuter, "Port monitor started") |
| 1617 | } |
| 1618 | } |
| 1619 | |
| Philip Zeyliger | 194bfa8 | 2025-06-24 06:03:06 -0700 | [diff] [blame] | 1620 | // Set up cleanup when context is done |
| 1621 | defer func() { |
| 1622 | if a.mcpManager != nil { |
| 1623 | a.mcpManager.Close() |
| 1624 | } |
| Philip Zeyliger | 5f26a34 | 2025-07-04 01:30:29 +0000 | [diff] [blame] | 1625 | if a.portMonitor != nil && a.IsInContainer() { |
| 1626 | a.portMonitor.Stop() |
| 1627 | } |
| Philip Zeyliger | 194bfa8 | 2025-06-24 06:03:06 -0700 | [diff] [blame] | 1628 | }() |
| 1629 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1630 | for { |
| 1631 | select { |
| 1632 | case <-ctxOuter.Done(): |
| 1633 | return |
| 1634 | default: |
| 1635 | ctxInner, cancel := context.WithCancelCause(ctxOuter) |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 1636 | a.cancelTurnMu.Lock() |
| 1637 | // Set .cancelTurn so the user can cancel whatever is happening |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1638 | // inside the conversation loop without canceling this outer Loop execution. |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 1639 | // This cancelTurn func is intended be called from other goroutines, |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1640 | // hence the mutex. |
| Sean McCullough | edc88dc | 2025-04-30 02:55:01 +0000 | [diff] [blame] | 1641 | a.cancelTurn = cancel |
| 1642 | a.cancelTurnMu.Unlock() |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1643 | err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose |
| 1644 | if err != nil { |
| 1645 | slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err) |
| 1646 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1647 | cancel(nil) |
| 1648 | } |
| 1649 | } |
| 1650 | } |
| 1651 | |
| 1652 | func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) { |
| 1653 | if m.Timestamp.IsZero() { |
| 1654 | m.Timestamp = time.Now() |
| 1655 | } |
| 1656 | |
| Philip Zeyliger | 72252cb | 2025-05-10 17:00:08 -0700 | [diff] [blame] | 1657 | // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content |
| 1658 | if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" { |
| 1659 | m.Content = m.ToolResult |
| 1660 | } |
| 1661 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1662 | // If this is an end-of-turn message, calculate the turn duration and add it to the message |
| 1663 | if m.EndOfTurn && m.Type == AgentMessageType { |
| 1664 | turnDuration := time.Since(a.startOfTurn) |
| 1665 | m.TurnDuration = &turnDuration |
| 1666 | slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration) |
| 1667 | } |
| 1668 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1669 | a.mu.Lock() |
| 1670 | defer a.mu.Unlock() |
| 1671 | m.Idx = len(a.history) |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 1672 | slog.InfoContext(ctx, "agent message", m.Attr()) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1673 | a.history = append(a.history, m) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1674 | |
| Philip Zeyliger | b7c5875 | 2025-05-01 10:10:17 -0700 | [diff] [blame] | 1675 | // Notify all subscribers |
| 1676 | for _, ch := range a.subscribers { |
| 1677 | ch <- &m |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1678 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1679 | } |
| 1680 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1681 | func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) { |
| 1682 | var m []llm.Content |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1683 | if block { |
| 1684 | select { |
| 1685 | case <-ctx.Done(): |
| 1686 | return m, ctx.Err() |
| 1687 | case msg := <-a.inbox: |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1688 | m = append(m, llm.StringContent(msg)) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1689 | } |
| 1690 | } |
| 1691 | for { |
| 1692 | select { |
| 1693 | case msg := <-a.inbox: |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1694 | m = append(m, llm.StringContent(msg)) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1695 | default: |
| 1696 | return m, nil |
| 1697 | } |
| 1698 | } |
| 1699 | } |
| 1700 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1701 | // processTurn handles a single conversation turn with the user |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1702 | func (a *Agent) processTurn(ctx context.Context) error { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1703 | // Reset the start of turn time |
| 1704 | a.startOfTurn = time.Now() |
| 1705 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1706 | // Transition to waiting for user input state |
| 1707 | a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn") |
| 1708 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1709 | // Process initial user message |
| 1710 | initialResp, err := a.processUserMessage(ctx) |
| 1711 | if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1712 | a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error()) |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1713 | return err |
| 1714 | } |
| 1715 | |
| 1716 | // Handle edge case where both initialResp and err are nil |
| 1717 | if initialResp == nil { |
| 1718 | err := fmt.Errorf("unexpected nil response from processUserMessage with no error") |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1719 | a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error()) |
| 1720 | |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1721 | a.pushToOutbox(ctx, errorMessage(err)) |
| 1722 | return err |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1723 | } |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1724 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1725 | // We do this as we go, but let's also do it at the end of the turn |
| 1726 | defer func() { |
| 1727 | if _, err := a.handleGitCommits(ctx); err != nil { |
| 1728 | // Just log the error, don't stop execution |
| 1729 | slog.WarnContext(ctx, "Failed to check for new git commits", "error", err) |
| 1730 | } |
| 1731 | }() |
| 1732 | |
| Sean McCullough | a1e0e49 | 2025-05-01 10:51:08 -0700 | [diff] [blame] | 1733 | // 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] | 1734 | resp := initialResp |
| 1735 | for { |
| 1736 | // Check if we are over budget |
| 1737 | if err := a.overBudget(ctx); err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1738 | a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error()) |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1739 | return err |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1740 | } |
| 1741 | |
| Philip Zeyliger | b8a8f35 | 2025-06-02 07:39:37 -0700 | [diff] [blame] | 1742 | // Check if we should compact the conversation |
| 1743 | if a.ShouldCompact() { |
| 1744 | a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation") |
| 1745 | if err := a.CompactConversation(ctx); err != nil { |
| 1746 | a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error()) |
| 1747 | return err |
| 1748 | } |
| 1749 | // After compaction, end this turn and start fresh |
| 1750 | a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn") |
| 1751 | return nil |
| 1752 | } |
| 1753 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1754 | // 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] | 1755 | if resp.StopReason != llm.StopReasonToolUse { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1756 | a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1757 | break |
| 1758 | } |
| 1759 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1760 | // Transition to tool use requested state |
| 1761 | a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use") |
| 1762 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1763 | // Handle tool execution |
| 1764 | continueConversation, toolResp := a.handleToolExecution(ctx, resp) |
| 1765 | if !continueConversation { |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1766 | return nil |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1767 | } |
| 1768 | |
| Sean McCullough | a1e0e49 | 2025-05-01 10:51:08 -0700 | [diff] [blame] | 1769 | if toolResp == nil { |
| 1770 | return fmt.Errorf("cannot continue conversation with a nil tool response") |
| 1771 | } |
| 1772 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1773 | // Set the response for the next iteration |
| 1774 | resp = toolResp |
| 1775 | } |
| Sean McCullough | 9f4b808 | 2025-04-30 17:34:07 +0000 | [diff] [blame] | 1776 | |
| 1777 | return nil |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1778 | } |
| 1779 | |
| 1780 | // processUserMessage waits for user messages and sends them to the model |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1781 | func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) { |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1782 | // Wait for at least one message from the user |
| 1783 | msgs, err := a.GatherMessages(ctx, true) |
| 1784 | 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] | 1785 | a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1786 | return nil, err |
| 1787 | } |
| 1788 | |
| Josh Bleecher Snyder | 3b44cc3 | 2025-07-22 02:28:14 +0000 | [diff] [blame] | 1789 | // Auto-generate slug if this is the first user input and no slug is set |
| 1790 | if a.Slug() == "" { |
| 1791 | if err := a.autoGenerateSlug(ctx, msgs); err != nil { |
| 1792 | // NB: it is possible that autoGenerateSlug set the slug during the process |
| 1793 | // of trying to generate a slug. |
| 1794 | // The fact that it returned an error means that we cannot use that slug. |
| 1795 | slog.WarnContext(ctx, "Failed to auto-generate slug", "error", err) |
| 1796 | // use the session id instead. ugly, but we need a slug, and this will be unique. |
| 1797 | a.SetSlug(a.SessionID()) |
| 1798 | } |
| 1799 | // Notify termui of the final slug (only emitted once, after slug is determined) |
| 1800 | a.pushToOutbox(ctx, AgentMessage{ |
| 1801 | Type: SlugMessageType, |
| 1802 | Content: a.Slug(), |
| 1803 | }) |
| 1804 | } |
| 1805 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1806 | userMessage := llm.Message{ |
| 1807 | Role: llm.MessageRoleUser, |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1808 | Content: msgs, |
| 1809 | } |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1810 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1811 | // Transition to sending to LLM state |
| 1812 | a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM") |
| 1813 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1814 | // Send message to the model |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1815 | resp, err := a.convo.SendMessage(userMessage) |
| 1816 | if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1817 | a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error()) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1818 | a.pushToOutbox(ctx, errorMessage(err)) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1819 | return nil, err |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1820 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1821 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1822 | // Transition to processing LLM response state |
| 1823 | a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response") |
| 1824 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1825 | return resp, nil |
| 1826 | } |
| 1827 | |
| 1828 | // handleToolExecution processes a tool use request from the model |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1829 | func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) { |
| 1830 | var results []llm.Content |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1831 | cancelled := false |
| Josh Bleecher Snyder | 64f2aa8 | 2025-05-14 18:31:05 +0000 | [diff] [blame] | 1832 | toolEndsTurn := false |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1833 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1834 | // Transition to checking for cancellation state |
| 1835 | a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation") |
| 1836 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1837 | // Check if the operation was cancelled by the user |
| 1838 | select { |
| 1839 | case <-ctx.Done(): |
| 1840 | // Don't actually run any of the tools, but rather build a response |
| 1841 | // for each tool_use message letting the LLM know that user canceled it. |
| 1842 | var err error |
| 1843 | results, err = a.convo.ToolResultCancelContents(resp) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1844 | if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1845 | a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1846 | a.pushToOutbox(ctx, errorMessage(err)) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1847 | } |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1848 | cancelled = true |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1849 | a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1850 | default: |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1851 | // Transition to running tool state |
| 1852 | a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool") |
| 1853 | |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 1854 | // Add working directory and session ID to context for tool execution |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1855 | ctx = claudetool.WithWorkingDir(ctx, a.workingDir) |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 1856 | ctx = claudetool.WithSessionID(ctx, a.config.SessionID) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1857 | |
| 1858 | // Execute the tools |
| 1859 | var err error |
| Josh Bleecher Snyder | 64f2aa8 | 2025-05-14 18:31:05 +0000 | [diff] [blame] | 1860 | results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1861 | if ctx.Err() != nil { // e.g. the user canceled the operation |
| 1862 | cancelled = true |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1863 | a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1864 | } else if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1865 | a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1866 | a.pushToOutbox(ctx, errorMessage(err)) |
| 1867 | } |
| 1868 | } |
| 1869 | |
| 1870 | // Process git commits that may have occurred during tool execution |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1871 | a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1872 | autoqualityMessages := a.processGitChanges(ctx) |
| 1873 | |
| 1874 | // Check budget again after tool execution |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1875 | a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1876 | if err := a.overBudget(ctx); err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1877 | a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1878 | return false, nil |
| 1879 | } |
| 1880 | |
| 1881 | // Continue the conversation with tool results and any user messages |
| Josh Bleecher Snyder | 64f2aa8 | 2025-05-14 18:31:05 +0000 | [diff] [blame] | 1882 | shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled) |
| 1883 | return shouldContinue && !toolEndsTurn, resp |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1884 | } |
| 1885 | |
| Philip Zeyliger | 75bd37d | 2025-05-22 18:49:14 +0000 | [diff] [blame] | 1886 | // DetectGitChanges checks for new git commits and pushes them if found |
| Philip Zeyliger | 9bca61e | 2025-05-22 12:40:06 -0700 | [diff] [blame] | 1887 | func (a *Agent) DetectGitChanges(ctx context.Context) error { |
| Philip Zeyliger | 75bd37d | 2025-05-22 18:49:14 +0000 | [diff] [blame] | 1888 | // Check for git commits |
| 1889 | _, err := a.handleGitCommits(ctx) |
| 1890 | if err != nil { |
| Philip Zeyliger | 75bd37d | 2025-05-22 18:49:14 +0000 | [diff] [blame] | 1891 | slog.WarnContext(ctx, "Failed to check for new git commits", "error", err) |
| Philip Zeyliger | 9bca61e | 2025-05-22 12:40:06 -0700 | [diff] [blame] | 1892 | return fmt.Errorf("failed to check for new git commits: %w", err) |
| Philip Zeyliger | 75bd37d | 2025-05-22 18:49:14 +0000 | [diff] [blame] | 1893 | } |
| Philip Zeyliger | 9bca61e | 2025-05-22 12:40:06 -0700 | [diff] [blame] | 1894 | return nil |
| Philip Zeyliger | 75bd37d | 2025-05-22 18:49:14 +0000 | [diff] [blame] | 1895 | } |
| 1896 | |
| 1897 | // processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated |
| 1898 | // This is used internally by the agent loop |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1899 | func (a *Agent) processGitChanges(ctx context.Context) []string { |
| 1900 | // Check for git commits after tool execution |
| 1901 | newCommits, err := a.handleGitCommits(ctx) |
| 1902 | if err != nil { |
| 1903 | // Just log the error, don't stop execution |
| 1904 | slog.WarnContext(ctx, "Failed to check for new git commits", "error", err) |
| 1905 | return nil |
| 1906 | } |
| 1907 | |
| Josh Bleecher Snyder | c72ceb2 | 2025-05-05 23:30:15 +0000 | [diff] [blame] | 1908 | // Run mechanical checks if there was exactly one new commit. |
| 1909 | if len(newCommits) != 1 { |
| 1910 | return nil |
| 1911 | } |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1912 | var autoqualityMessages []string |
| Josh Bleecher Snyder | c72ceb2 | 2025-05-05 23:30:15 +0000 | [diff] [blame] | 1913 | a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit") |
| 1914 | msg := a.codereview.RunMechanicalChecks(ctx) |
| 1915 | if msg != "" { |
| 1916 | a.pushToOutbox(ctx, AgentMessage{ |
| 1917 | Type: AutoMessageType, |
| 1918 | Content: msg, |
| 1919 | Timestamp: time.Now(), |
| 1920 | }) |
| 1921 | autoqualityMessages = append(autoqualityMessages, msg) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1922 | } |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1923 | |
| 1924 | return autoqualityMessages |
| 1925 | } |
| 1926 | |
| 1927 | // continueTurnWithToolResults continues the conversation with tool results |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1928 | 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] | 1929 | // Get any messages the user sent while tools were executing |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1930 | a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages") |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1931 | msgs, err := a.GatherMessages(ctx, false) |
| 1932 | if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1933 | a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1934 | return false, nil |
| 1935 | } |
| 1936 | |
| 1937 | // Inject any auto-generated messages from quality checks |
| 1938 | for _, msg := range autoqualityMessages { |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1939 | msgs = append(msgs, llm.StringContent(msg)) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1940 | } |
| 1941 | |
| 1942 | // Handle cancellation by appending a message about it |
| 1943 | if cancelled { |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1944 | msgs = append(msgs, llm.StringContent(cancelToolUseMessage)) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1945 | // 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] | 1946 | // further messages; the conversation is not over. |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1947 | a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false}) |
| 1948 | } else if err := a.convo.OverBudget(); err != nil { |
| 1949 | // Handle budget issues by appending a message about it |
| 1950 | 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] | 1951 | msgs = append(msgs, llm.StringContent(budgetMsg)) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1952 | a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err))) |
| 1953 | } |
| 1954 | |
| 1955 | // Combine tool results with user messages |
| 1956 | results = append(results, msgs...) |
| 1957 | |
| 1958 | // Send the combined message to continue the conversation |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1959 | a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM") |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1960 | resp, err := a.convo.SendMessage(llm.Message{ |
| 1961 | Role: llm.MessageRoleUser, |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1962 | Content: results, |
| 1963 | }) |
| 1964 | if err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1965 | a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error()) |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1966 | a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error()))) |
| 1967 | return true, nil // Return true to continue the conversation, but with no response |
| 1968 | } |
| 1969 | |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1970 | // Transition back to processing LLM response |
| 1971 | a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results") |
| 1972 | |
| Sean McCullough | 885a16a | 2025-04-30 02:49:25 +0000 | [diff] [blame] | 1973 | if cancelled { |
| 1974 | return false, nil |
| 1975 | } |
| 1976 | |
| 1977 | return true, resp |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1978 | } |
| 1979 | |
| 1980 | func (a *Agent) overBudget(ctx context.Context) error { |
| 1981 | if err := a.convo.OverBudget(); err != nil { |
| Sean McCullough | 96b60dd | 2025-04-30 09:49:10 -0700 | [diff] [blame] | 1982 | a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error()) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1983 | m := budgetMessage(err) |
| 1984 | m.Content = m.Content + "\n\nBudget reset." |
| David Crawshaw | 35c72bc | 2025-05-20 11:17:10 -0700 | [diff] [blame] | 1985 | a.pushToOutbox(ctx, m) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1986 | a.convo.ResetBudget(a.originalBudget) |
| 1987 | return err |
| 1988 | } |
| 1989 | return nil |
| 1990 | } |
| 1991 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1992 | func collectTextContent(msg *llm.Response) string { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1993 | // Collect all text content |
| 1994 | var allText strings.Builder |
| 1995 | for _, content := range msg.Content { |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 1996 | if content.Type == llm.ContentTypeText && content.Text != "" { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1997 | if allText.Len() > 0 { |
| 1998 | allText.WriteString("\n\n") |
| 1999 | } |
| 2000 | allText.WriteString(content.Text) |
| 2001 | } |
| 2002 | } |
| 2003 | return allText.String() |
| 2004 | } |
| 2005 | |
| Josh Bleecher Snyder | 4f84ab7 | 2025-04-22 16:40:54 -0700 | [diff] [blame] | 2006 | func (a *Agent) TotalUsage() conversation.CumulativeUsage { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2007 | a.mu.Lock() |
| 2008 | defer a.mu.Unlock() |
| 2009 | return a.convo.CumulativeUsage() |
| 2010 | } |
| 2011 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2012 | // Diff returns a unified diff of changes made since the agent was instantiated. |
| 2013 | func (a *Agent) Diff(commit *string) (string, error) { |
| Philip Zeyliger | 49edc92 | 2025-05-14 09:45:45 -0700 | [diff] [blame] | 2014 | if a.SketchGitBase() == "" { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2015 | return "", fmt.Errorf("no initial commit reference available") |
| 2016 | } |
| 2017 | |
| 2018 | // Find the repository root |
| 2019 | ctx := context.Background() |
| 2020 | |
| 2021 | // If a specific commit hash is provided, show just that commit's changes |
| 2022 | if commit != nil && *commit != "" { |
| 2023 | // Validate that the commit looks like a valid git SHA |
| 2024 | if !isValidGitSHA(*commit) { |
| 2025 | return "", fmt.Errorf("invalid git commit SHA format: %s", *commit) |
| 2026 | } |
| 2027 | |
| 2028 | // Get the diff for just this commit |
| 2029 | cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit) |
| 2030 | cmd.Dir = a.repoRoot |
| 2031 | output, err := cmd.CombinedOutput() |
| 2032 | if err != nil { |
| 2033 | return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output)) |
| 2034 | } |
| 2035 | return string(output), nil |
| 2036 | } |
| 2037 | |
| 2038 | // Otherwise, get the diff between the initial commit and the current state using exec.Command |
| Philip Zeyliger | 49edc92 | 2025-05-14 09:45:45 -0700 | [diff] [blame] | 2039 | cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef()) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2040 | cmd.Dir = a.repoRoot |
| 2041 | output, err := cmd.CombinedOutput() |
| 2042 | if err != nil { |
| 2043 | return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output)) |
| 2044 | } |
| 2045 | |
| 2046 | return string(output), nil |
| 2047 | } |
| 2048 | |
| Philip Zeyliger | 49edc92 | 2025-05-14 09:45:45 -0700 | [diff] [blame] | 2049 | // SketchGitBaseRef distinguishes between the typical container version, where sketch-base is |
| 2050 | // unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate. |
| 2051 | func (a *Agent) SketchGitBaseRef() string { |
| 2052 | if a.IsInContainer() { |
| 2053 | return "sketch-base" |
| 2054 | } else { |
| 2055 | return "sketch-base-" + a.SessionID() |
| 2056 | } |
| 2057 | } |
| 2058 | |
| 2059 | // SketchGitBase returns the Git commit hash that was saved when the agent was instantiated. |
| 2060 | func (a *Agent) SketchGitBase() string { |
| 2061 | cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef()) |
| 2062 | cmd.Dir = a.repoRoot |
| 2063 | output, err := cmd.CombinedOutput() |
| 2064 | if err != nil { |
| 2065 | slog.Warn("could not identify sketch-base", slog.String("error", err.Error())) |
| 2066 | return "HEAD" |
| 2067 | } |
| 2068 | return string(strings.TrimSpace(string(output))) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2069 | } |
| 2070 | |
| Pokey Rule | 7a11362 | 2025-05-12 10:58:45 +0100 | [diff] [blame] | 2071 | // removeGitHooks removes the Git hooks directory from the repository |
| 2072 | func removeGitHooks(_ context.Context, repoPath string) error { |
| 2073 | hooksDir := filepath.Join(repoPath, ".git", "hooks") |
| 2074 | |
| 2075 | // Check if hooks directory exists |
| 2076 | if _, err := os.Stat(hooksDir); os.IsNotExist(err) { |
| 2077 | // Directory doesn't exist, nothing to do |
| 2078 | return nil |
| 2079 | } |
| 2080 | |
| 2081 | // Remove the hooks directory |
| 2082 | err := os.RemoveAll(hooksDir) |
| 2083 | if err != nil { |
| 2084 | return fmt.Errorf("failed to remove git hooks directory: %w", err) |
| 2085 | } |
| 2086 | |
| 2087 | // Create an empty hooks directory to prevent git from recreating default hooks |
| Autoformatter | e577ef7 | 2025-05-12 10:29:00 +0000 | [diff] [blame] | 2088 | err = os.MkdirAll(hooksDir, 0o755) |
| Pokey Rule | 7a11362 | 2025-05-12 10:58:45 +0100 | [diff] [blame] | 2089 | if err != nil { |
| 2090 | return fmt.Errorf("failed to create empty git hooks directory: %w", err) |
| 2091 | } |
| 2092 | |
| 2093 | return nil |
| 2094 | } |
| 2095 | |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2096 | func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) { |
| Josh Bleecher Snyder | eb91caa | 2025-07-11 15:29:18 -0700 | [diff] [blame] | 2097 | msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix) |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2098 | for _, msg := range msgs { |
| 2099 | a.pushToOutbox(ctx, msg) |
| 2100 | } |
| 2101 | return commits, error |
| 2102 | } |
| 2103 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2104 | // handleGitCommits() highlights new commits to the user. When running |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 2105 | // under docker, new HEADs are pushed to a branch according to the slug. |
| Josh Bleecher Snyder | eb91caa | 2025-07-11 15:29:18 -0700 | [diff] [blame] | 2106 | func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) { |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2107 | ags.mu.Lock() |
| 2108 | defer ags.mu.Unlock() |
| 2109 | |
| 2110 | msgs := []AgentMessage{} |
| 2111 | if repoRoot == "" { |
| 2112 | return msgs, nil, nil |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2113 | } |
| 2114 | |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 2115 | sketch, err := resolveRef(ctx, repoRoot, "sketch-wip") |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2116 | if err != nil { |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2117 | return msgs, nil, err |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2118 | } |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 2119 | if sketch == ags.lastSketch { |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2120 | return msgs, nil, nil // nothing to do |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2121 | } |
| 2122 | defer func() { |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 2123 | ags.lastSketch = sketch |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2124 | }() |
| 2125 | |
| Philip Zeyliger | 64f6046 | 2025-06-16 13:57:10 -0700 | [diff] [blame] | 2126 | // Compute diff stats from baseRef to HEAD when HEAD changes |
| 2127 | if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil { |
| 2128 | // Log error but don't fail the entire operation |
| 2129 | slog.WarnContext(ctx, "Failed to compute diff stats", "error", err) |
| 2130 | } else { |
| 2131 | // Set diff stats directly since we already hold the mutex |
| 2132 | ags.linesAdded = added |
| 2133 | ags.linesRemoved = removed |
| 2134 | } |
| 2135 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2136 | // Get new commits. Because it's possible that the agent does rebases, fixups, and |
| 2137 | // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves |
| 2138 | // to the last 100 commits. |
| 2139 | var commits []*GitCommit |
| 2140 | |
| 2141 | // Get commits since the initial commit |
| 2142 | // Format: <hash>\0<subject>\0<body>\0 |
| 2143 | // This uses NULL bytes as separators to avoid issues with newlines in commit messages |
| 2144 | // Limit to 100 commits to avoid overwhelming the user |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 2145 | cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, sketch) |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2146 | cmd.Dir = repoRoot |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2147 | output, err := cmd.Output() |
| 2148 | if err != nil { |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2149 | return msgs, nil, fmt.Errorf("failed to get git log: %w", err) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2150 | } |
| 2151 | |
| 2152 | // Parse git log output and filter out already seen commits |
| 2153 | parsedCommits := parseGitLog(string(output)) |
| 2154 | |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 2155 | var sketchCommit *GitCommit |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2156 | |
| 2157 | // Filter out commits we've already seen |
| 2158 | for _, commit := range parsedCommits { |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 2159 | if commit.Hash == sketch { |
| 2160 | sketchCommit = &commit |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2161 | } |
| 2162 | |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 2163 | // Skip if we've seen this commit before. If our sketch branch has changed, always include that. |
| 2164 | if ags.seenCommits[commit.Hash] && commit.Hash != sketch { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2165 | continue |
| 2166 | } |
| 2167 | |
| 2168 | // Mark this commit as seen |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2169 | ags.seenCommits[commit.Hash] = true |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2170 | |
| 2171 | // Add to our list of new commits |
| 2172 | commits = append(commits, &commit) |
| 2173 | } |
| 2174 | |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2175 | if ags.gitRemoteAddr != "" { |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 2176 | if sketchCommit == nil { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2177 | // I think this can only happen if we have a bug or if there's a race. |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 2178 | sketchCommit = &GitCommit{} |
| 2179 | sketchCommit.Hash = sketch |
| 2180 | sketchCommit.Subject = "unknown" |
| 2181 | commits = append(commits, sketchCommit) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2182 | } |
| 2183 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2184 | // TODO: I don't love the force push here. We could see if the push is a fast-forward, and, |
| 2185 | // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and |
| 2186 | // then use push with lease to replace. |
| Philip Zeyliger | 113e205 | 2025-05-09 21:59:40 +0000 | [diff] [blame] | 2187 | |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 2188 | // Try up to 10 times with incrementing retry numbers if the branch is checked out on the remote |
| Philip Zeyliger | 113e205 | 2025-05-09 21:59:40 +0000 | [diff] [blame] | 2189 | var out []byte |
| 2190 | var err error |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 2191 | originalRetryNumber := ags.retryNumber |
| 2192 | originalBranchName := ags.branchNameLocked(branchPrefix) |
| Philip Zeyliger | 113e205 | 2025-05-09 21:59:40 +0000 | [diff] [blame] | 2193 | for retries := range 10 { |
| 2194 | if retries > 0 { |
| Philip Zeyliger | d5c8d71 | 2025-06-17 15:19:45 -0700 | [diff] [blame] | 2195 | ags.retryNumber++ |
| Philip Zeyliger | 113e205 | 2025-05-09 21:59:40 +0000 | [diff] [blame] | 2196 | } |
| 2197 | |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 2198 | branch := ags.branchNameLocked(branchPrefix) |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 2199 | cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch) |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2200 | cmd.Dir = repoRoot |
| Philip Zeyliger | 113e205 | 2025-05-09 21:59:40 +0000 | [diff] [blame] | 2201 | out, err = cmd.CombinedOutput() |
| 2202 | |
| 2203 | if err == nil { |
| 2204 | // Success! Break out of the retry loop |
| 2205 | break |
| 2206 | } |
| 2207 | |
| 2208 | // Check if this is the "refusing to update checked out branch" error |
| 2209 | if !strings.Contains(string(out), "refusing to update checked out branch") { |
| 2210 | // This is a different error, so don't retry |
| 2211 | break |
| 2212 | } |
| Philip Zeyliger | 113e205 | 2025-05-09 21:59:40 +0000 | [diff] [blame] | 2213 | } |
| 2214 | |
| 2215 | if err != nil { |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2216 | msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err))) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2217 | } else { |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 2218 | finalBranch := ags.branchNameLocked(branchPrefix) |
| Josh Bleecher Snyder | 715b8d9 | 2025-06-06 12:36:38 -0700 | [diff] [blame] | 2219 | sketchCommit.PushedBranch = finalBranch |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 2220 | if ags.retryNumber != originalRetryNumber { |
| 2221 | // Notify user that the branch name was changed, and why |
| Philip Zeyliger | 59e1c16 | 2025-06-02 12:54:34 +0000 | [diff] [blame] | 2222 | msgs = append(msgs, AgentMessage{ |
| 2223 | Type: AutoMessageType, |
| 2224 | Timestamp: time.Now(), |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 2225 | Content: fmt.Sprintf("Branch renamed from %s to %s because the original branch is currently checked out on the remote.", originalBranchName, finalBranch), |
| Philip Zeyliger | 59e1c16 | 2025-06-02 12:54:34 +0000 | [diff] [blame] | 2226 | }) |
| Philip Zeyliger | 113e205 | 2025-05-09 21:59:40 +0000 | [diff] [blame] | 2227 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2228 | } |
| 2229 | } |
| 2230 | |
| 2231 | // If we found new commits, create a message |
| 2232 | if len(commits) > 0 { |
| 2233 | msg := AgentMessage{ |
| 2234 | Type: CommitMessageType, |
| 2235 | Timestamp: time.Now(), |
| 2236 | Commits: commits, |
| 2237 | } |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2238 | msgs = append(msgs, msg) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2239 | } |
| Philip Zeyliger | f287299 | 2025-05-22 10:35:28 -0700 | [diff] [blame] | 2240 | return msgs, commits, nil |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2241 | } |
| 2242 | |
| Josh Bleecher Snyder | 19969a9 | 2025-06-05 14:34:02 -0700 | [diff] [blame] | 2243 | func cleanSlugName(s string) string { |
| Josh Bleecher Snyder | 1ae976b | 2025-04-30 00:06:43 +0000 | [diff] [blame] | 2244 | return strings.Map(func(r rune) rune { |
| 2245 | // lowercase |
| 2246 | if r >= 'A' && r <= 'Z' { |
| 2247 | return r + 'a' - 'A' |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2248 | } |
| Josh Bleecher Snyder | 1ae976b | 2025-04-30 00:06:43 +0000 | [diff] [blame] | 2249 | // replace spaces with dashes |
| 2250 | if r == ' ' { |
| 2251 | return '-' |
| 2252 | } |
| 2253 | // allow alphanumerics and dashes |
| 2254 | if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') { |
| 2255 | return r |
| 2256 | } |
| 2257 | return -1 |
| 2258 | }, s) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2259 | } |
| 2260 | |
| 2261 | // parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00' |
| 2262 | // and returns an array of GitCommit structs. |
| 2263 | func parseGitLog(output string) []GitCommit { |
| 2264 | var commits []GitCommit |
| 2265 | |
| 2266 | // No output means no commits |
| 2267 | if len(output) == 0 { |
| 2268 | return commits |
| 2269 | } |
| 2270 | |
| 2271 | // Split by NULL byte |
| 2272 | parts := strings.Split(output, "\x00") |
| 2273 | |
| 2274 | // Process in triplets (hash, subject, body) |
| 2275 | for i := 0; i < len(parts); i++ { |
| 2276 | // Skip empty parts |
| 2277 | if parts[i] == "" { |
| 2278 | continue |
| 2279 | } |
| 2280 | |
| 2281 | // This should be a hash |
| 2282 | hash := strings.TrimSpace(parts[i]) |
| 2283 | |
| 2284 | // Make sure we have at least a subject part available |
| 2285 | if i+1 >= len(parts) { |
| 2286 | break // No more parts available |
| 2287 | } |
| 2288 | |
| 2289 | // Get the subject |
| 2290 | subject := strings.TrimSpace(parts[i+1]) |
| 2291 | |
| 2292 | // Get the body if available |
| 2293 | body := "" |
| 2294 | if i+2 < len(parts) { |
| 2295 | body = strings.TrimSpace(parts[i+2]) |
| 2296 | } |
| 2297 | |
| 2298 | // Skip to the next triplet |
| 2299 | i += 2 |
| 2300 | |
| 2301 | commits = append(commits, GitCommit{ |
| 2302 | Hash: hash, |
| 2303 | Subject: subject, |
| 2304 | Body: body, |
| 2305 | }) |
| 2306 | } |
| 2307 | |
| 2308 | return commits |
| 2309 | } |
| 2310 | |
| 2311 | func repoRoot(ctx context.Context, dir string) (string, error) { |
| 2312 | cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") |
| 2313 | stderr := new(strings.Builder) |
| 2314 | cmd.Stderr = stderr |
| 2315 | cmd.Dir = dir |
| 2316 | out, err := cmd.Output() |
| 2317 | if err != nil { |
| Philip Zeyliger | bc8c8dc | 2025-05-21 13:19:13 -0700 | [diff] [blame] | 2318 | return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2319 | } |
| 2320 | return strings.TrimSpace(string(out)), nil |
| 2321 | } |
| 2322 | |
| Josh Bleecher Snyder | 369f262 | 2025-07-15 00:02:59 +0000 | [diff] [blame] | 2323 | // upsertRemoteOrigin configures the origin remote to point to the given URL. |
| 2324 | // If the origin remote exists, it updates the URL. If it doesn't exist, it adds it. |
| Philip Zeyliger | 254c49f | 2025-07-17 17:26:24 -0700 | [diff] [blame] | 2325 | // |
| 2326 | // NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL. |
| Josh Bleecher Snyder | 369f262 | 2025-07-15 00:02:59 +0000 | [diff] [blame] | 2327 | func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error { |
| 2328 | // Try to set the URL for existing origin remote |
| 2329 | cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL) |
| 2330 | cmd.Dir = repoDir |
| 2331 | if _, err := cmd.CombinedOutput(); err == nil { |
| 2332 | // Success. |
| 2333 | return nil |
| 2334 | } |
| 2335 | // Origin doesn't exist; add it. |
| 2336 | cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL) |
| 2337 | cmd.Dir = repoDir |
| 2338 | if out, err := cmd.CombinedOutput(); err != nil { |
| 2339 | return fmt.Errorf("failed to add git remote origin: %s: %w", out, err) |
| 2340 | } |
| 2341 | return nil |
| 2342 | } |
| 2343 | |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 2344 | func resolveRef(ctx context.Context, dir, refName string) (string, error) { |
| 2345 | cmd := exec.CommandContext(ctx, "git", "rev-parse", refName) |
| 2346 | stderr := new(strings.Builder) |
| 2347 | cmd.Stderr = stderr |
| 2348 | cmd.Dir = dir |
| 2349 | out, err := cmd.Output() |
| 2350 | if err != nil { |
| 2351 | return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr) |
| 2352 | } |
| 2353 | // TODO: validate that out is valid hex |
| 2354 | return strings.TrimSpace(string(out)), nil |
| 2355 | } |
| 2356 | |
| 2357 | // isValidGitSHA validates if a string looks like a valid git SHA hash. |
| 2358 | // Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters. |
| 2359 | func isValidGitSHA(sha string) bool { |
| 2360 | // Git SHA must be a hexadecimal string with at least 4 characters |
| 2361 | if len(sha) < 4 || len(sha) > 40 { |
| 2362 | return false |
| 2363 | } |
| 2364 | |
| 2365 | // Check if the string only contains hexadecimal characters |
| 2366 | for _, char := range sha { |
| 2367 | if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') { |
| 2368 | return false |
| 2369 | } |
| 2370 | } |
| 2371 | |
| 2372 | return true |
| 2373 | } |
| Philip Zeyliger | d140295 | 2025-04-23 03:54:37 +0000 | [diff] [blame] | 2374 | |
| Philip Zeyliger | 64f6046 | 2025-06-16 13:57:10 -0700 | [diff] [blame] | 2375 | // computeDiffStats computes the number of lines added and removed from baseRef to HEAD |
| 2376 | func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) { |
| 2377 | cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD") |
| 2378 | cmd.Dir = repoRoot |
| 2379 | out, err := cmd.Output() |
| 2380 | if err != nil { |
| 2381 | return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err) |
| 2382 | } |
| 2383 | |
| 2384 | var totalAdded, totalRemoved int |
| 2385 | lines := strings.Split(strings.TrimSpace(string(out)), "\n") |
| 2386 | for _, line := range lines { |
| 2387 | if line == "" { |
| 2388 | continue |
| 2389 | } |
| 2390 | parts := strings.Fields(line) |
| 2391 | if len(parts) < 2 { |
| 2392 | continue |
| 2393 | } |
| 2394 | // Format: <added>\t<removed>\t<filename> |
| 2395 | if added, err := strconv.Atoi(parts[0]); err == nil { |
| 2396 | totalAdded += added |
| 2397 | } |
| 2398 | if removed, err := strconv.Atoi(parts[1]); err == nil { |
| 2399 | totalRemoved += removed |
| 2400 | } |
| 2401 | } |
| 2402 | |
| 2403 | return totalAdded, totalRemoved, nil |
| 2404 | } |
| 2405 | |
| Josh Bleecher Snyder | 5cca56f | 2025-05-06 01:10:16 +0000 | [diff] [blame] | 2406 | // systemPromptData contains the data used to render the system prompt template |
| 2407 | type systemPromptData struct { |
| David Crawshaw | c886ac5 | 2025-06-13 23:40:03 +0000 | [diff] [blame] | 2408 | ClientGOOS string |
| 2409 | ClientGOARCH string |
| 2410 | WorkingDir string |
| 2411 | RepoRoot string |
| 2412 | InitialCommit string |
| 2413 | Codebase *onstart.Codebase |
| 2414 | UseSketchWIP bool |
| Philip Zeyliger | e67e3b6 | 2025-07-24 16:54:21 -0700 | [diff] [blame] | 2415 | InstallationNudge bool |
| David Crawshaw | c886ac5 | 2025-06-13 23:40:03 +0000 | [diff] [blame] | 2416 | Branch string |
| 2417 | SpecialInstruction string |
| Josh Bleecher Snyder | 8a0de52 | 2025-07-24 19:29:07 +0000 | [diff] [blame^] | 2418 | Now string |
| Josh Bleecher Snyder | 5cca56f | 2025-05-06 01:10:16 +0000 | [diff] [blame] | 2419 | } |
| 2420 | |
| 2421 | // renderSystemPrompt renders the system prompt template. |
| 2422 | func (a *Agent) renderSystemPrompt() string { |
| Josh Bleecher Snyder | 8a0de52 | 2025-07-24 19:29:07 +0000 | [diff] [blame^] | 2423 | nowFn := a.now |
| 2424 | if nowFn == nil { |
| 2425 | nowFn = time.Now |
| 2426 | } |
| 2427 | now := nowFn() |
| Josh Bleecher Snyder | 5cca56f | 2025-05-06 01:10:16 +0000 | [diff] [blame] | 2428 | data := systemPromptData{ |
| Philip Zeyliger | e67e3b6 | 2025-07-24 16:54:21 -0700 | [diff] [blame] | 2429 | ClientGOOS: a.config.ClientGOOS, |
| 2430 | ClientGOARCH: a.config.ClientGOARCH, |
| 2431 | WorkingDir: a.workingDir, |
| 2432 | RepoRoot: a.repoRoot, |
| 2433 | InitialCommit: a.SketchGitBase(), |
| 2434 | Codebase: a.codebase, |
| 2435 | UseSketchWIP: a.config.InDocker, |
| 2436 | InstallationNudge: a.config.InDocker, |
| Josh Bleecher Snyder | 8a0de52 | 2025-07-24 19:29:07 +0000 | [diff] [blame^] | 2437 | Now: now.Format(time.DateTime), |
| Josh Bleecher Snyder | 5cca56f | 2025-05-06 01:10:16 +0000 | [diff] [blame] | 2438 | } |
| David Crawshaw | c886ac5 | 2025-06-13 23:40:03 +0000 | [diff] [blame] | 2439 | if now.Month() == time.September && now.Day() == 19 { |
| 2440 | data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code." |
| 2441 | } |
| 2442 | |
| Josh Bleecher Snyder | 5cca56f | 2025-05-06 01:10:16 +0000 | [diff] [blame] | 2443 | tmpl, err := template.New("system").Parse(agentSystemPrompt) |
| 2444 | if err != nil { |
| 2445 | panic(fmt.Sprintf("failed to parse system prompt template: %v", err)) |
| 2446 | } |
| 2447 | buf := new(strings.Builder) |
| 2448 | err = tmpl.Execute(buf, data) |
| 2449 | if err != nil { |
| 2450 | panic(fmt.Sprintf("failed to execute system prompt template: %v", err)) |
| 2451 | } |
| Josh Bleecher Snyder | a997be6 | 2025-05-07 22:52:46 +0000 | [diff] [blame] | 2452 | // fmt.Printf("system prompt: %s\n", buf.String()) |
| Josh Bleecher Snyder | 5cca56f | 2025-05-06 01:10:16 +0000 | [diff] [blame] | 2453 | return buf.String() |
| 2454 | } |
| Philip Zeyliger | eab12de | 2025-05-14 02:35:53 +0000 | [diff] [blame] | 2455 | |
| 2456 | // StateTransitionIterator provides an iterator over state transitions. |
| 2457 | type StateTransitionIterator interface { |
| 2458 | // Next blocks until a new state transition is available or context is done. |
| 2459 | // Returns nil if the context is cancelled. |
| 2460 | Next() *StateTransition |
| 2461 | // Close removes the listener and cleans up resources. |
| 2462 | Close() |
| 2463 | } |
| 2464 | |
| 2465 | // StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener. |
| 2466 | type StateTransitionIteratorImpl struct { |
| 2467 | agent *Agent |
| 2468 | ctx context.Context |
| 2469 | ch chan StateTransition |
| 2470 | unsubscribe func() |
| 2471 | } |
| 2472 | |
| 2473 | // Next blocks until a new state transition is available or the context is cancelled. |
| 2474 | func (s *StateTransitionIteratorImpl) Next() *StateTransition { |
| 2475 | select { |
| 2476 | case <-s.ctx.Done(): |
| 2477 | return nil |
| 2478 | case transition, ok := <-s.ch: |
| 2479 | if !ok { |
| 2480 | return nil |
| 2481 | } |
| 2482 | transitionCopy := transition |
| 2483 | return &transitionCopy |
| 2484 | } |
| 2485 | } |
| 2486 | |
| 2487 | // Close removes the listener and cleans up resources. |
| 2488 | func (s *StateTransitionIteratorImpl) Close() { |
| 2489 | if s.unsubscribe != nil { |
| 2490 | s.unsubscribe() |
| 2491 | s.unsubscribe = nil |
| 2492 | } |
| 2493 | } |
| 2494 | |
| 2495 | // NewStateTransitionIterator returns an iterator that receives state transitions. |
| 2496 | func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator { |
| 2497 | a.mu.Lock() |
| 2498 | defer a.mu.Unlock() |
| 2499 | |
| 2500 | // Create channel to receive state transitions |
| 2501 | ch := make(chan StateTransition, 10) |
| 2502 | |
| 2503 | // Add a listener to the state machine |
| 2504 | unsubscribe := a.stateMachine.AddTransitionListener(ch) |
| 2505 | |
| 2506 | return &StateTransitionIteratorImpl{ |
| 2507 | agent: a, |
| 2508 | ctx: ctx, |
| 2509 | ch: ch, |
| 2510 | unsubscribe: unsubscribe, |
| 2511 | } |
| 2512 | } |
| Josh Bleecher Snyder | 039fc34 | 2025-05-14 21:24:12 +0000 | [diff] [blame] | 2513 | |
| 2514 | // setupGitHooks creates or updates git hooks in the specified working directory. |
| 2515 | func setupGitHooks(workingDir string) error { |
| 2516 | hooksDir := filepath.Join(workingDir, ".git", "hooks") |
| 2517 | |
| 2518 | _, err := os.Stat(hooksDir) |
| 2519 | if os.IsNotExist(err) { |
| 2520 | return fmt.Errorf("git hooks directory does not exist: %s", hooksDir) |
| 2521 | } |
| 2522 | if err != nil { |
| 2523 | return fmt.Errorf("error checking git hooks directory: %w", err) |
| 2524 | } |
| 2525 | |
| 2526 | // Define the post-commit hook content |
| 2527 | postCommitHook := `#!/bin/bash |
| 2528 | echo "<post_commit_hook>" |
| 2529 | echo "Please review this commit message and fix it if it is incorrect." |
| 2530 | echo "This hook only echos the commit message; it does not modify it." |
| 2531 | echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'." |
| 2532 | echo "<last_commit_message>" |
| Philip Zeyliger | 6c5beff | 2025-06-06 13:03:49 -0700 | [diff] [blame] | 2533 | PAGER=cat git log -1 --pretty=%B |
| Josh Bleecher Snyder | 039fc34 | 2025-05-14 21:24:12 +0000 | [diff] [blame] | 2534 | echo "</last_commit_message>" |
| 2535 | echo "</post_commit_hook>" |
| 2536 | ` |
| 2537 | |
| 2538 | // Define the prepare-commit-msg hook content |
| 2539 | prepareCommitMsgHook := `#!/bin/bash |
| 2540 | # Add Co-Authored-By and Change-ID trailers to commit messages |
| 2541 | # Check if these trailers already exist before adding them |
| 2542 | |
| 2543 | commit_file="$1" |
| 2544 | COMMIT_SOURCE="$2" |
| 2545 | |
| 2546 | # Skip for merges, squashes, or when using a commit template |
| 2547 | if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \ |
| 2548 | [ "$COMMIT_SOURCE" = "squash" ]; then |
| 2549 | exit 0 |
| 2550 | fi |
| 2551 | |
| 2552 | commit_msg=$(cat "$commit_file") |
| 2553 | |
| 2554 | needs_co_author=true |
| 2555 | needs_change_id=true |
| 2556 | |
| 2557 | # Check if commit message already has Co-Authored-By trailer |
| 2558 | if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then |
| 2559 | needs_co_author=false |
| 2560 | fi |
| 2561 | |
| 2562 | # Check if commit message already has Change-ID trailer |
| 2563 | if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then |
| 2564 | needs_change_id=false |
| 2565 | fi |
| 2566 | |
| 2567 | # Only modify if at least one trailer needs to be added |
| 2568 | if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then |
| Josh Bleecher Snyder | b509a5d | 2025-05-23 15:49:42 +0000 | [diff] [blame] | 2569 | # Ensure there's a proper blank line before trailers |
| 2570 | if [ -s "$commit_file" ]; then |
| 2571 | # Check if file ends with newline by reading last character |
| 2572 | last_char=$(tail -c 1 "$commit_file") |
| 2573 | |
| 2574 | if [ "$last_char" != "" ]; then |
| 2575 | # File doesn't end with newline - add two newlines (complete line + blank line) |
| 2576 | echo "" >> "$commit_file" |
| 2577 | echo "" >> "$commit_file" |
| 2578 | else |
| 2579 | # File ends with newline - check if we already have a blank line |
| 2580 | last_line=$(tail -1 "$commit_file") |
| 2581 | if [ -n "$last_line" ]; then |
| 2582 | # Last line has content - add one newline for blank line |
| 2583 | echo "" >> "$commit_file" |
| 2584 | fi |
| 2585 | # If last line is empty, we already have a blank line - don't add anything |
| 2586 | fi |
| Josh Bleecher Snyder | 039fc34 | 2025-05-14 21:24:12 +0000 | [diff] [blame] | 2587 | fi |
| 2588 | |
| 2589 | # Add trailers if needed |
| 2590 | if [ "$needs_co_author" = true ]; then |
| 2591 | echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file" |
| 2592 | fi |
| 2593 | |
| 2594 | if [ "$needs_change_id" = true ]; then |
| 2595 | change_id=$(openssl rand -hex 8) |
| 2596 | echo "Change-ID: s${change_id}k" >> "$commit_file" |
| 2597 | fi |
| 2598 | fi |
| 2599 | ` |
| 2600 | |
| 2601 | // Update or create the post-commit hook |
| 2602 | err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>") |
| 2603 | if err != nil { |
| 2604 | return fmt.Errorf("failed to set up post-commit hook: %w", err) |
| 2605 | } |
| 2606 | |
| 2607 | // Update or create the prepare-commit-msg hook |
| 2608 | err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers") |
| 2609 | if err != nil { |
| 2610 | return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err) |
| 2611 | } |
| 2612 | |
| 2613 | return nil |
| 2614 | } |
| 2615 | |
| 2616 | // updateOrCreateHook creates a new hook file or updates an existing one |
| 2617 | // by appending the new content if it doesn't already contain it. |
| 2618 | func updateOrCreateHook(hookPath, content, distinctiveLine string) error { |
| 2619 | // Check if the hook already exists |
| 2620 | buf, err := os.ReadFile(hookPath) |
| 2621 | if os.IsNotExist(err) { |
| 2622 | // Hook doesn't exist, create it |
| 2623 | err = os.WriteFile(hookPath, []byte(content), 0o755) |
| 2624 | if err != nil { |
| 2625 | return fmt.Errorf("failed to create hook: %w", err) |
| 2626 | } |
| 2627 | return nil |
| 2628 | } |
| 2629 | if err != nil { |
| 2630 | return fmt.Errorf("error reading existing hook: %w", err) |
| 2631 | } |
| 2632 | |
| 2633 | // Hook exists, check if our content is already in it by looking for a distinctive line |
| 2634 | code := string(buf) |
| 2635 | if strings.Contains(code, distinctiveLine) { |
| 2636 | // Already contains our content, nothing to do |
| 2637 | return nil |
| 2638 | } |
| 2639 | |
| 2640 | // Append our content to the existing hook |
| 2641 | f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755) |
| 2642 | if err != nil { |
| 2643 | return fmt.Errorf("failed to open hook for appending: %w", err) |
| 2644 | } |
| 2645 | defer f.Close() |
| 2646 | |
| 2647 | // Ensure there's a newline at the end of the existing content if needed |
| 2648 | if len(code) > 0 && !strings.HasSuffix(code, "\n") { |
| 2649 | _, err = f.WriteString("\n") |
| 2650 | if err != nil { |
| 2651 | return fmt.Errorf("failed to add newline to hook: %w", err) |
| 2652 | } |
| 2653 | } |
| 2654 | |
| 2655 | // Add a separator before our content |
| 2656 | _, err = f.WriteString("\n# === Added by Sketch ===\n" + content) |
| 2657 | if err != nil { |
| 2658 | return fmt.Errorf("failed to append to hook: %w", err) |
| 2659 | } |
| 2660 | |
| 2661 | return nil |
| 2662 | } |
| Sean McCullough | 138ec24 | 2025-06-02 22:42:06 +0000 | [diff] [blame] | 2663 | |
| Philip Zeyliger | 254c49f | 2025-07-17 17:26:24 -0700 | [diff] [blame] | 2664 | // configurePassthroughUpstream configures git remotes |
| 2665 | // Adds an upstream remote pointing to the same as origin |
| 2666 | // Sets the refspec for upstream and fetch such that both |
| 2667 | // fetch the upstream's things into refs/remotes/upstream/foo |
| 2668 | // The typical scenario is: |
| 2669 | // |
| 2670 | // github - laptop - sketch container |
| 2671 | // "upstream" "origin" |
| 2672 | func (a *Agent) configurePassthroughUpstream(ctx context.Context) error { |
| 2673 | // Get the origin remote URL |
| 2674 | cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin") |
| 2675 | cmd.Dir = a.workingDir |
| 2676 | originURLBytes, err := cmd.CombinedOutput() |
| 2677 | if err != nil { |
| 2678 | return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err) |
| 2679 | } |
| 2680 | originURL := strings.TrimSpace(string(originURLBytes)) |
| 2681 | |
| 2682 | // Check if upstream remote already exists |
| 2683 | cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream") |
| 2684 | cmd.Dir = a.workingDir |
| 2685 | if _, err := cmd.CombinedOutput(); err != nil { |
| 2686 | // upstream remote doesn't exist, create it |
| 2687 | cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL) |
| 2688 | cmd.Dir = a.workingDir |
| 2689 | if out, err := cmd.CombinedOutput(); err != nil { |
| 2690 | return fmt.Errorf("failed to add upstream remote: %s: %w", out, err) |
| 2691 | } |
| 2692 | slog.InfoContext(ctx, "added upstream remote", "url", originURL) |
| 2693 | } else { |
| 2694 | // upstream remote exists, update its URL |
| 2695 | cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL) |
| 2696 | cmd.Dir = a.workingDir |
| 2697 | if out, err := cmd.CombinedOutput(); err != nil { |
| 2698 | return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err) |
| 2699 | } |
| 2700 | slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL) |
| 2701 | } |
| 2702 | |
| 2703 | // Add the upstream refspec to the upstream remote |
| 2704 | cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*") |
| 2705 | cmd.Dir = a.workingDir |
| 2706 | if out, err := cmd.CombinedOutput(); err != nil { |
| 2707 | return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err) |
| 2708 | } |
| 2709 | |
| 2710 | // Add the same refspec to the origin remote |
| 2711 | cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*") |
| 2712 | cmd.Dir = a.workingDir |
| 2713 | if out, err := cmd.CombinedOutput(); err != nil { |
| 2714 | return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err) |
| 2715 | } |
| 2716 | |
| 2717 | slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL) |
| 2718 | return nil |
| 2719 | } |
| 2720 | |
| Philip Zeyliger | 0113be5 | 2025-06-07 23:53:41 +0000 | [diff] [blame] | 2721 | // SkabandAddr returns the skaband address if configured |
| 2722 | func (a *Agent) SkabandAddr() string { |
| 2723 | if a.config.SkabandClient != nil { |
| 2724 | return a.config.SkabandClient.Addr() |
| 2725 | } |
| 2726 | return "" |
| 2727 | } |