turbo-editors/turbo-corepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

config.go · 275 lines · 8.6 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 12h ago1// 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?")
13package acp
14
15import (
16 "errors"
17 "fmt"
18 "os"
19 "path/filepath"
20 "strings"
21
22 "github.com/BurntSushi/toml"
23
📦 Turbo Core f3ade8d k33g 4h ago24 "rickub.com/turbo-editors/turbo-core/profile"
🛟 Updated. 28d5985 k33g 12h ago25)
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.
29const 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.
33var 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.
41type 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`
72func (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.
80type List struct {
81 agents []Agent
82}
83
84// Agents returns the agents, in file order.
85func (l List) Agents() []Agent { return l.agents }
86
87// Len returns how many there are.
88func (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)")
93func (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"
105func 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.
115func 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.
127func 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, ".")
144func 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.
164func 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.
177func 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.
194func 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.
207func 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.
217type 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.
225func 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.
242func 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.
250func 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.
266func 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}