// 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 }