16 "golang.org/x/sys/unix"
20 // agentInstructionsFileName is the file of the user's instructions in
21 // the directory of an agent.
22 agentInstructionsFileName = "instructions.md"
24 // agentIDBytes is the number of random bytes of an agent ID.
28 // openAgent reads the config.json of the agent in dir. When the file does not
29 // exist, it makes the directory and writes the file with the defaults and a new
30 // ID. A missing field of an existing file takes the default, but the ID must be
31 // there. An unknown field is an error, so a typo does not become a silent
33 func openAgent(dir string) (*Agent, error) {
34 err := os.MkdirAll(dir, 0o700)
36 return nil, fmt.Errorf("create agent directory: %w", err)
39 path := filepath.Join(dir, "config.json")
40 data, err := os.ReadFile(path)
41 if errors.Is(err, os.ErrNotExist) {
42 return createAgent(dir)
46 return nil, fmt.Errorf("read agent config: %w", err)
49 config, err := defaultAgentConfig()
54 decoder := json.NewDecoder(bytes.NewReader(data))
55 decoder.DisallowUnknownFields()
57 err = decoder.Decode(&config)
58 if err == nil && !errors.Is(decoder.Decode(&struct{}{}), io.EOF) {
59 err = errors.New("data after the JSON object")
63 err = config.validate()
67 return nil, fmt.Errorf("%s: %w", path, err)
76 // Agent is one agent and its directory. The directory holds config.json, the
77 // user's instructions in instructions.md, the chat, and the usage file.
83 // Images returns the shared data-directory image store, also for a child.
84 func (a *Agent) Images() *ImageStore {
86 if a.config.Parent != "" {
87 root = filepath.Dir(filepath.Dir(root))
90 return &ImageStore{dir: filepath.Join(filepath.Dir(root), imageDirName)}
93 // Voices returns the shared data-directory audio store, also for a child.
94 func (a *Agent) Voices() *VoiceStore {
96 if a.config.Parent != "" {
97 root = filepath.Dir(filepath.Dir(root))
100 return &VoiceStore{dir: filepath.Join(filepath.Dir(root), voiceDirName)}
103 // CacheKey returns the prompt cache key of every model call of the agent.
104 func (a *Agent) CacheKey() string {
105 return "onechat-" + a.config.ID
108 // Prompt returns the system prompt of the agent with the user's instructions
109 // from instructions.md. It reads the file on each call, so a change applies at
110 // the next call. A missing file means no instructions.
111 func (a *Agent) Prompt() (string, error) {
112 instructions, err := a.instructions()
114 return "", fmt.Errorf("read prompt instructions: %w", err)
117 path, err := filepath.Abs(filepath.Join(a.dir, agentInstructionsFileName))
119 return "", fmt.Errorf("find instructions path: %w", err)
122 if a.config.Parent != "" {
123 return promptChild(a.config.Name, a.config.Parent, a.config.Responsibility, path, instructions), nil
126 return prompt(a.config.Name, path, instructions), nil
129 // Tools returns the tools shared by the turns and compactions of this agent. A
130 // child has no image output, WhatsApp, child creation, or route tools.
131 func (a *Agent) Tools() []ModelTool {
132 tools := promptTools()
133 if a.config.Parent != "" {
134 tools = slices.DeleteFunc(tools, func(tool ModelTool) bool {
135 return strings.HasPrefix(tool.Name, "whatsapp_") || tool.Name == "image_show" || tool.Name == "agent_create" || tool.Name == "route" || tool.Name == "unroute" || tool.Name == "routes"
142 // Children reads the direct children in directory order. Each child needs an
143 // existing config with its directory name and this agent as parent. Children
144 // cannot have children. A missing children directory means no children.
145 func (a *Agent) Children() ([]*Agent, error) {
146 dir := filepath.Join(a.dir, "children")
147 entries, err := os.ReadDir(dir)
148 if errors.Is(err, os.ErrNotExist) {
153 return nil, fmt.Errorf("read children: %w", err)
156 if a.config.Parent != "" && len(entries) != 0 {
157 return nil, errors.New("children cannot have children")
160 var children []*Agent
162 keys := map[string]bool{a.CacheKey(): true}
164 for _, entry := range entries {
167 return nil, fmt.Errorf("child %q must be a directory", name)
170 path := filepath.Join(dir, name)
171 _, err = os.Stat(filepath.Join(path, "config.json"))
173 return nil, fmt.Errorf("child %q config: %w", name, err)
176 child, err := openAgent(path)
178 return nil, fmt.Errorf("open child %q: %w", name, err)
181 if child.config.Name != name || child.config.Parent != a.config.Name {
182 return nil, fmt.Errorf("child %q needs name %q and parent %q", name, name, a.config.Name)
185 if child.config.Name == a.config.Name || keys[child.CacheKey()] {
186 return nil, fmt.Errorf("child %q must have its own name and ID", name)
189 keys[child.CacheKey()] = true
190 _, err = child.Children()
192 return nil, fmt.Errorf("read child %q: %w", name, err)
195 children = append(children, child)
201 // CreateChild writes a new direct child with its own ID and the model, effort,
202 // and working directory of the root. Child and subagent names share a
203 // namespace. The config is complete before an atomic rename publishes the
204 // child. Preparation stays outside children, so an interrupted write cannot
206 func (a *Agent) CreateChild(name, responsibility string, subagents *Subagents) (_ *Agent, err error) {
207 if a.config.Parent != "" {
208 return nil, errors.New("only the root can create children")
211 if !agentChildNameValid(name) {
212 return nil, errors.New("name must have 2 to 32 ASCII letters and start with a capital letter")
215 if name == a.config.Name {
216 return nil, errors.New("the name is already used by the root")
219 responsibility = strings.TrimSpace(responsibility)
220 if responsibility == "" {
221 return nil, errors.New("responsibility must not be empty")
224 err = subagents.Reserve(name)
226 return nil, fmt.Errorf("reserve child name: %w", err)
232 subagents.Release(name)
236 staged, err := os.MkdirTemp(a.dir, ".child-*")
238 return nil, fmt.Errorf("prepare child directory: %w", err)
242 cleanupErr := os.RemoveAll(staged)
243 if cleanupErr != nil {
244 err = errors.Join(err, fmt.Errorf("remove prepared child: %w", cleanupErr))
251 config.Parent = a.config.Name
252 config.Responsibility = responsibility
253 child, err := writeAgent(staged, config)
255 return nil, fmt.Errorf("write child: %w", err)
258 dir := filepath.Join(a.dir, "children")
259 err = os.MkdirAll(dir, 0o700)
261 return nil, fmt.Errorf("create children directory: %w", err)
264 dir = filepath.Join(dir, name)
265 err = unix.Renameat2(unix.AT_FDCWD, staged, unix.AT_FDCWD, dir, unix.RENAME_NOREPLACE)
267 return nil, fmt.Errorf("publish child directory: %w", err)
276 // SubagentPrompt returns the system prompt of a subagent of the agent, with the
277 // user's instructions as Prompt reads them.
278 func (a *Agent) SubagentPrompt() (string, error) {
279 instructions, err := a.instructions()
284 return promptSubagent(a.config.Name, instructions), nil
287 // instructions reads the user's instructions from instructions.md. A missing
288 // file means no instructions.
289 func (a *Agent) instructions() (string, error) {
290 data, err := os.ReadFile(filepath.Join(a.dir, agentInstructionsFileName))
291 if err != nil && !errors.Is(err, os.ErrNotExist) {
292 return "", fmt.Errorf("read instructions: %w", err)
295 return string(data), nil
298 // AgentConfig holds the settings of the config.json of an agent. ID is random,
299 // made once when the file is made, and never changes. The cache key comes from
300 // the ID, so a rename keeps the prompt cache and two agents with one name do
301 // not share a key. Workdir is the directory where the commands of the agent
302 // run. Responsibility and Parent are set together for a child agent.
303 type AgentConfig struct {
304 Name string `json:"name"`
305 ID string `json:"id"`
306 Model string `json:"model"`
307 Effort string `json:"effort"`
308 Workdir string `json:"workdir"`
309 Responsibility string `json:"responsibility,omitempty"`
310 Parent string `json:"parent,omitempty"`
313 // validate makes sure that each setting has a valid value.
314 func (c AgentConfig) validate() error {
315 if c.Name == "" || strings.ContainsAny(c.Name, "\r\n") {
316 return errors.New("name must be one line of text")
319 id, err := hex.DecodeString(c.ID)
320 if err != nil || len(id) != agentIDBytes {
321 return fmt.Errorf("id must be %d hexadecimal digits", 2*agentIDBytes)
324 if c.Model == "" || c.Effort == "" {
325 return errors.New("agent needs a model and an effort")
328 if (strings.TrimSpace(c.Responsibility) == "") != (c.Parent == "") {
329 return errors.New("a child needs both responsibility and parent")
332 if strings.ContainsAny(c.Parent, "\r\n") {
333 return errors.New("parent must be one line of text")
336 if !filepath.IsAbs(c.Workdir) {
337 return fmt.Errorf("workdir %q is not an absolute path", c.Workdir)
343 // agentChildNameValid accepts one word of 2 to 32 ASCII letters with an initial
344 // capital letter. Child names describe their area.
345 func agentChildNameValid(name string) bool {
346 if len(name) < 2 || len(name) > 32 || name[0] < 'A' || name[0] > 'Z' {
350 for i := 1; i < len(name); i++ {
352 if (letter < 'A' || letter > 'Z') && (letter < 'a' || letter > 'z') {
360 // createAgent writes the config.json of a new agent in dir with the defaults
362 func createAgent(dir string) (*Agent, error) {
363 config, err := defaultAgentConfig()
368 return writeAgent(dir, config)
371 // writeAgent gives config a new ID and writes it in dir.
372 func writeAgent(dir string, config AgentConfig) (*Agent, error) {
373 id := make([]byte, agentIDBytes)
374 _, err := rand.Read(id)
376 return nil, fmt.Errorf("make agent ID: %w", err)
379 config.ID = hex.EncodeToString(id)
380 data, err := json.MarshalIndent(config, "", " ")
382 return nil, fmt.Errorf("encode agent config: %w", err)
385 err = writeFileAtomic(filepath.Join(dir, "config.json"), append(data, '\n'))
387 return nil, fmt.Errorf("write agent config: %w", err)
396 // defaultAgentConfig returns the settings of a new agent, without an ID. The
397 // commands run in the home directory of the user.
398 func defaultAgentConfig() (AgentConfig, error) {
399 home, err := os.UserHomeDir()
401 return AgentConfig{}, fmt.Errorf("find working directory: %w", err)
406 Model: "gpt-6.1-sol",