| 🛟 Updated. 28d5985 k33g 14h ago | 1 | // Package acp is a client for the Agent Client Protocol: it starts a coding |
| 2 | // agent as a child process, holds a conversation with it, and keeps a model of |
| 3 | // that conversation a window can draw. |
| 4 | // |
| 5 | // The protocol is JSON-RPC 2.0 with messages separated by newlines. The |
| 6 | // JSON-RPC half is jsonrpc's, shared with the language server client; what is |
| 7 | // here is the framing, the methods, and the conversation an agent expects. |
| 8 | // |
| 9 | // list, err := acp.Load(p, ".") |
| 10 | // session, err := acp.Start(list.Agents()[0], ".", acp.Options{OnUpdate: redraw}) |
| 11 | // defer session.Close() |
| 12 | // session.Prompt("what does buildMenus do?") |
| 13 | package acp |
| 14 | |
| 15 | import ( |
| 16 | "errors" |
| 17 | "fmt" |
| 18 | "os" |
| 19 | "path/filepath" |
| 20 | "strings" |
| 21 | |
| 22 | "github.com/BurntSushi/toml" |
| 23 | |
| 📦 Turbo Core f3ade8d k33g 7h ago | 24 | "rickub.com/turbo-editors/turbo-core/profile" |
| 🛟 Updated. 28d5985 k33g 14h ago | 25 | ) |
| 26 | |
| 27 | // FileName is the file the agents are listed in. It sits in the editor's own |
| 28 | // directory, the same one settings.toml, snippets.toml and tools.toml live in. |
| 29 | const FileName = "acp.toml" |
| 30 | |
| 31 | // ErrExists is returned by Create when the project already has an agents file, |
| 32 | // so that creating one never silently overwrites what somebody wrote. |
| 33 | var ErrExists = errors.New("acp: project agents already exist") |
| 34 | |
| 35 | // Agent is one agent the editor can open a window on. |
| 36 | // |
| 37 | // It is how to *start* a program, not how to talk to one: everything about the |
| 38 | // conversation is the protocol's, and everything about the model, the provider |
| 39 | // and the tools is the agent's own configuration file, which this editor does |
| 40 | // not read. |
| 41 | type Agent struct { |
| 42 | // Name is what the Agent menu shows and what the window is titled. It is |
| 43 | // also how a project's file replaces one of the user's, so it has to be |
| 44 | // unique across the two. |
| 45 | Name string `toml:"name"` |
| 46 | |
| 47 | // Command is the executable to run: "docker", "my-agent". It is looked up |
| 48 | // on PATH unless it contains a separator. |
| 49 | Command string `toml:"command"` |
| 50 | |
| 51 | // Args are its arguments, passed as given. There is no shell, so no |
| 52 | // quoting, globbing or && — `command = "sh"` with `args = ["-c", …]` is how |
| 53 | // to ask for one deliberately. |
| 54 | Args []string `toml:"args"` |
| 55 | |
| 56 | // Env are environment variables added to the ones the editor was started |
| 57 | // with. A name given here wins over an inherited one. |
| 58 | Env map[string]string `toml:"env"` |
| 59 | |
| 60 | // Cwd is where the process starts, and the working directory the agent is |
| 61 | // told about. It is relative to the project, and empty means the project |
| 62 | // itself. |
| 63 | Cwd string `toml:"cwd"` |
| 64 | } |
| 65 | |
| 66 | // CommandLine returns the agent's command and arguments as one readable line. |
| 67 | // |
| 68 | // It is what the status dialog shows, so that "the agent will not start" can be |
| 69 | // answered by looking at what was actually run. |
| 70 | // |
| 71 | // agent.CommandLine() // `docker agent serve acp .turbo-go/agent.yaml` |
| 72 | func (a Agent) CommandLine() string { |
| 73 | return strings.TrimSpace(a.Command + " " + strings.Join(a.Args, " ")) |
| 74 | } |
| 75 | |
| 76 | // List is the agents the editor knows about, in the order they were read. |
| 77 | // |
| 78 | // The order is the file's, so somebody reordering the file sees the menu |
| 79 | // reorder. |
| 80 | type List struct { |
| 81 | agents []Agent |
| 82 | } |
| 83 | |
| 84 | // Agents returns the agents, in file order. |
| 85 | func (l List) Agents() []Agent { return l.agents } |
| 86 | |
| 87 | // Len returns how many there are. |
| 88 | func (l List) Len() int { return len(l.agents) } |
| 89 | |
| 90 | // ByName returns the agent of that name, and whether there was one. |
| 91 | // |
| 92 | // agent, ok := list.ByName("Bob (llama.cpp)") |
| 93 | func (l List) ByName(name string) (Agent, bool) { |
| 94 | for _, agent := range l.agents { |
| 95 | if agent.Name == name { |
| 96 | return agent, true |
| 97 | } |
| 98 | } |
| 99 | return Agent{}, false |
| 100 | } |
| 101 | |
| 102 | // ProjectPath returns where a project keeps its agents. |
| 103 | // |
| 104 | // acp.ProjectPath(turboGo, "/src/p") // "/src/p/.turbo-go/acp.toml" |
| 105 | func ProjectPath(p profile.Profile, projectDir string) string { |
| 106 | return filepath.Join(projectDir, p.ProjectDir(), FileName) |
| 107 | } |
| 108 | |
| 109 | // UserPath returns the user's own agents file, or "" when there is nowhere to |
| 110 | // look for one. |
| 111 | // |
| 112 | // An agent is a program you have installed and configured, which is a fact |
| 113 | // about you rather than about one repository — the same reasoning that gives |
| 114 | // snippets a user-level file and tools none. |
| 115 | func UserPath(p profile.Profile) string { |
| 116 | dir := p.UserDir() |
| 117 | if dir == "" { |
| 118 | return "" |
| 119 | } |
| 120 | return filepath.Join(dir, FileName) |
| 121 | } |
| 122 | |
| 123 | // Exists reports whether the project has an agents file that can be read. |
| 124 | // |
| 125 | // A directory in its place counts as absent: it is not something Load could |
| 126 | // have read. |
| 127 | func Exists(p profile.Profile, projectDir string) bool { |
| 128 | info, err := os.Stat(ProjectPath(p, projectDir)) |
| 129 | return err == nil && info.Mode().IsRegular() |
| 130 | } |
| 131 | |
| 132 | // Load reads the user's agents and the project's, and returns them together. |
| 133 | // |
| 134 | // The **user's** come first and the **project's** after, so that a project can |
| 135 | // add to what you already have; where a Name clashes the project's wins, being |
| 136 | // the more specific statement of the two. |
| 137 | // |
| 138 | // A missing file is not an error — most projects have none, and a user may have |
| 139 | // none either. A file that is present but unreadable *is* an error, and so is |
| 140 | // one whose contents do not make sense: a half-loaded menu offering three of |
| 141 | // your five agents is worse than a message saying which line is wrong. |
| 142 | // |
| 143 | // list, err := acp.Load(p, ".") |
| 144 | func Load(p profile.Profile, projectDir string) (List, error) { |
| 145 | var list List |
| 146 | |
| 147 | for _, path := range []string{UserPath(p), ProjectPath(p, projectDir)} { |
| 148 | if path == "" { |
| 149 | continue |
| 150 | } |
| 151 | read, err := loadFile(path) |
| 152 | if err != nil { |
| 153 | return List{}, err |
| 154 | } |
| 155 | list.agents = merge(list.agents, read) |
| 156 | } |
| 157 | if err := checkNamesAreUnique(list.agents); err != nil { |
| 158 | return List{}, err |
| 159 | } |
| 160 | return list, nil |
| 161 | } |
| 162 | |
| 163 | // loadFile reads one agents file, treating a missing one as empty. |
| 164 | func loadFile(path string) ([]Agent, error) { |
| 165 | data, err := os.ReadFile(path) |
| 166 | if errors.Is(err, os.ErrNotExist) { |
| 167 | return nil, nil |
| 168 | } |
| 169 | if err != nil { |
| 170 | return nil, fmt.Errorf("reading %s: %w", path, err) |
| 171 | } |
| 172 | return decodeAgents(data, path) |
| 173 | } |
| 174 | |
| 175 | // decodeAgents turns one file's bytes into agents, refusing anything that |
| 176 | // would leave a menu somebody cannot act on. |
| 177 | func decodeAgents(data []byte, path string) ([]Agent, error) { |
| 178 | var f file |
| 179 | |
| 180 | meta, err := toml.Decode(string(data), &f) |
| 181 | if err != nil { |
| 182 | return nil, fmt.Errorf("reading %s: %w", path, err) |
| 183 | } |
| 184 | if err := checkNoUnknownKeys(meta, path); err != nil { |
| 185 | return nil, err |
| 186 | } |
| 187 | if err := check(f.Agent, path); err != nil { |
| 188 | return nil, err |
| 189 | } |
| 190 | return f.Agent, nil |
| 191 | } |
| 192 | |
| 193 | // merge appends the later agents, replacing any earlier one of the same name. |
| 194 | func merge(earlier, later []Agent) []Agent { |
| 195 | out := earlier |
| 196 | for _, agent := range later { |
| 197 | if at := indexOf(out, agent.Name); at >= 0 { |
| 198 | out[at] = agent |
| 199 | continue |
| 200 | } |
| 201 | out = append(out, agent) |
| 202 | } |
| 203 | return out |
| 204 | } |
| 205 | |
| 206 | // indexOf returns where an agent of that name already sits, or -1. |
| 207 | func indexOf(agents []Agent, name string) int { |
| 208 | for i, agent := range agents { |
| 209 | if agent.Name == name { |
| 210 | return i |
| 211 | } |
| 212 | } |
| 213 | return -1 |
| 214 | } |
| 215 | |
| 216 | // file mirrors the agents file's structure. |
| 217 | type file struct { |
| 218 | Agent []Agent `toml:"agent"` |
| 219 | } |
| 220 | |
| 221 | // check refuses an agent that could not be shown or could not be started. |
| 222 | // |
| 223 | // One with no name has nothing to put in a menu and nothing to title a window |
| 224 | // with; one with no command has nothing to run. |
| 225 | func check(agents []Agent, path string) error { |
| 226 | for i, agent := range agents { |
| 227 | if agent.Name == "" { |
| 228 | return fmt.Errorf("reading %s: agent %d has no name", path, i+1) |
| 229 | } |
| 230 | if agent.Command == "" { |
| 231 | return fmt.Errorf("reading %s: agent %q has no command", path, agent.Name) |
| 232 | } |
| 233 | } |
| 234 | return checkNamesAreUniqueIn(agents, path) |
| 235 | } |
| 236 | |
| 237 | // checkNoUnknownKeys refuses a key the format does not define. |
| 238 | // |
| 239 | // A misspelt key that was quietly ignored would look exactly like one that had |
| 240 | // no effect — `comand` would leave an agent that cannot start and a file that |
| 241 | // looks right. |
| 242 | func checkNoUnknownKeys(meta toml.MetaData, path string) error { |
| 243 | for _, key := range meta.Undecoded() { |
| 244 | return fmt.Errorf("reading %s: %s is not a key this file has", path, key.String()) |
| 245 | } |
| 246 | return nil |
| 247 | } |
| 248 | |
| 249 | // checkNamesAreUniqueIn refuses two agents of one name within a single file. |
| 250 | func checkNamesAreUniqueIn(agents []Agent, path string) error { |
| 251 | seen := map[string]bool{} |
| 252 | for _, agent := range agents { |
| 253 | if seen[agent.Name] { |
| 254 | return fmt.Errorf("reading %s: two agents are called %q", path, agent.Name) |
| 255 | } |
| 256 | seen[agent.Name] = true |
| 257 | } |
| 258 | return nil |
| 259 | } |
| 260 | |
| 261 | // checkNamesAreUnique repeats the check across the merged set. |
| 262 | // |
| 263 | // Merging replaces by name, so this can only fail if a single file already had |
| 264 | // a duplicate — but the merged list is what the menu is built from, and a menu |
| 265 | // with two identical lines is unusable whichever file caused it. |
| 266 | func checkNamesAreUnique(agents []Agent) error { |
| 267 | seen := map[string]bool{} |
| 268 | for _, agent := range agents { |
| 269 | if seen[agent.Name] { |
| 270 | return fmt.Errorf("acp: two agents are called %q", agent.Name) |
| 271 | } |
| 272 | seen[agent.Name] = true |
| 273 | } |
| 274 | return nil |
| 275 | } |