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