]> git.giacomolli.uk - onechat/blob - agent.go
Keep the README for the user
[onechat] / agent.go
1 package main
2
3 import (
4 "bytes"
5 "crypto/rand"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "os"
12 "path/filepath"
13 "slices"
14 "strings"
15
16 "golang.org/x/sys/unix"
17 )
18
19 const (
20 // agentInstructionsFileName is the file of the user's instructions in
21 // the directory of an agent.
22 agentInstructionsFileName = "instructions.md"
23
24 // agentIDBytes is the number of random bytes of an agent ID.
25 agentIDBytes = 16
26 )
27
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
32 // default.
33 func openAgent(dir string) (*Agent, error) {
34 err := os.MkdirAll(dir, 0o700)
35 if err != nil {
36 return nil, fmt.Errorf("create agent directory: %w", err)
37 }
38
39 path := filepath.Join(dir, "config.json")
40 data, err := os.ReadFile(path)
41 if errors.Is(err, os.ErrNotExist) {
42 return createAgent(dir)
43 }
44
45 if err != nil {
46 return nil, fmt.Errorf("read agent config: %w", err)
47 }
48
49 config, err := defaultAgentConfig()
50 if err != nil {
51 return nil, err
52 }
53
54 decoder := json.NewDecoder(bytes.NewReader(data))
55 decoder.DisallowUnknownFields()
56
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")
60 }
61
62 if err == nil {
63 err = config.validate()
64 }
65
66 if err != nil {
67 return nil, fmt.Errorf("%s: %w", path, err)
68 }
69
70 return &Agent{
71 dir: dir,
72 config: config,
73 }, nil
74 }
75
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.
78 type Agent struct {
79 dir string
80 config AgentConfig
81 }
82
83 // CacheKey returns the prompt cache key of every model call of the agent.
84 func (a *Agent) CacheKey() string {
85 return "onechat-" + a.config.ID
86 }
87
88 // Prompt returns the system prompt of the agent with the user's instructions
89 // from instructions.md. It reads the file on each call, so a change applies at
90 // the next call. A missing file means no instructions.
91 func (a *Agent) Prompt() (string, error) {
92 instructions, err := a.instructions()
93 if err != nil {
94 return "", fmt.Errorf("read prompt instructions: %w", err)
95 }
96
97 path, err := filepath.Abs(filepath.Join(a.dir, agentInstructionsFileName))
98 if err != nil {
99 return "", fmt.Errorf("find instructions path: %w", err)
100 }
101
102 if a.config.Parent != "" {
103 return promptChild(a.config.Name, a.config.Parent, a.config.Responsibility, path, instructions), nil
104 }
105
106 return prompt(a.config.Name, path, instructions), nil
107 }
108
109 // Tools returns the tools shared by the turns and compactions of this agent. A
110 // child has no WhatsApp, child creation, or route tools.
111 func (a *Agent) Tools() []ModelTool {
112 tools := promptTools()
113 if a.config.Parent != "" {
114 tools = slices.DeleteFunc(tools, func(tool ModelTool) bool {
115 return strings.HasPrefix(tool.Name, "whatsapp_") || tool.Name == "agent_create" || tool.Name == "route" || tool.Name == "unroute" || tool.Name == "routes"
116 })
117 }
118
119 return tools
120 }
121
122 // Children reads the direct children in directory order. Each child needs an
123 // existing config with its directory name and this agent as parent. Children
124 // cannot have children. A missing children directory means no children.
125 func (a *Agent) Children() ([]*Agent, error) {
126 dir := filepath.Join(a.dir, "children")
127 entries, err := os.ReadDir(dir)
128 if errors.Is(err, os.ErrNotExist) {
129 return nil, nil
130 }
131
132 if err != nil {
133 return nil, fmt.Errorf("read children: %w", err)
134 }
135
136 if a.config.Parent != "" && len(entries) != 0 {
137 return nil, errors.New("children cannot have children")
138 }
139
140 var children []*Agent
141
142 keys := map[string]bool{a.CacheKey(): true}
143
144 for _, entry := range entries {
145 name := entry.Name()
146 if !entry.IsDir() {
147 return nil, fmt.Errorf("child %q must be a directory", name)
148 }
149
150 path := filepath.Join(dir, name)
151 _, err = os.Stat(filepath.Join(path, "config.json"))
152 if err != nil {
153 return nil, fmt.Errorf("child %q config: %w", name, err)
154 }
155
156 child, err := openAgent(path)
157 if err != nil {
158 return nil, fmt.Errorf("open child %q: %w", name, err)
159 }
160
161 if child.config.Name != name || child.config.Parent != a.config.Name {
162 return nil, fmt.Errorf("child %q needs name %q and parent %q", name, name, a.config.Name)
163 }
164
165 if child.config.Name == a.config.Name || keys[child.CacheKey()] {
166 return nil, fmt.Errorf("child %q must have its own name and ID", name)
167 }
168
169 keys[child.CacheKey()] = true
170 _, err = child.Children()
171 if err != nil {
172 return nil, fmt.Errorf("read child %q: %w", name, err)
173 }
174
175 children = append(children, child)
176 }
177
178 return children, nil
179 }
180
181 // CreateChild writes a new direct child with its own ID and the model, effort,
182 // and working directory of the root. Child and subagent names share a
183 // namespace. The config is complete before an atomic rename publishes the
184 // child. Preparation stays outside children, so an interrupted write cannot
185 // stop startup.
186 func (a *Agent) CreateChild(name, responsibility string, subagents *Subagents) (_ *Agent, err error) {
187 if a.config.Parent != "" {
188 return nil, errors.New("only the root can create children")
189 }
190
191 if !agentChildNameValid(name) {
192 return nil, errors.New("name must have 2 to 32 ASCII letters and start with a capital letter")
193 }
194
195 if name == a.config.Name {
196 return nil, errors.New("the name is already used by the root")
197 }
198
199 responsibility = strings.TrimSpace(responsibility)
200 if responsibility == "" {
201 return nil, errors.New("responsibility must not be empty")
202 }
203
204 err = subagents.Reserve(name)
205 if err != nil {
206 return nil, fmt.Errorf("reserve child name: %w", err)
207 }
208
209 published := false
210 defer func() {
211 if !published {
212 subagents.Release(name)
213 }
214 }()
215
216 staged, err := os.MkdirTemp(a.dir, ".child-*")
217 if err != nil {
218 return nil, fmt.Errorf("prepare child directory: %w", err)
219 }
220
221 defer func() {
222 cleanupErr := os.RemoveAll(staged)
223 if cleanupErr != nil {
224 err = errors.Join(err, fmt.Errorf("remove prepared child: %w", cleanupErr))
225 }
226 }()
227
228 config := a.config
229 config.Name = name
230 config.ID = ""
231 config.Parent = a.config.Name
232 config.Responsibility = responsibility
233 child, err := writeAgent(staged, config)
234 if err != nil {
235 return nil, fmt.Errorf("write child: %w", err)
236 }
237
238 dir := filepath.Join(a.dir, "children")
239 err = os.MkdirAll(dir, 0o700)
240 if err != nil {
241 return nil, fmt.Errorf("create children directory: %w", err)
242 }
243
244 dir = filepath.Join(dir, name)
245 err = unix.Renameat2(unix.AT_FDCWD, staged, unix.AT_FDCWD, dir, unix.RENAME_NOREPLACE)
246 if err != nil {
247 return nil, fmt.Errorf("publish child directory: %w", err)
248 }
249
250 published = true
251 child.dir = dir
252
253 return child, nil
254 }
255
256 // SubagentPrompt returns the system prompt of a subagent of the agent, with the
257 // user's instructions as Prompt reads them.
258 func (a *Agent) SubagentPrompt() (string, error) {
259 instructions, err := a.instructions()
260 if err != nil {
261 return "", err
262 }
263
264 return promptSubagent(a.config.Name, instructions), nil
265 }
266
267 // instructions reads the user's instructions from instructions.md. A missing
268 // file means no instructions.
269 func (a *Agent) instructions() (string, error) {
270 data, err := os.ReadFile(filepath.Join(a.dir, agentInstructionsFileName))
271 if err != nil && !errors.Is(err, os.ErrNotExist) {
272 return "", fmt.Errorf("read instructions: %w", err)
273 }
274
275 return string(data), nil
276 }
277
278 // AgentConfig holds the settings of the config.json of an agent. ID is random,
279 // made once when the file is made, and never changes. The cache key comes from
280 // the ID, so a rename keeps the prompt cache and two agents with one name do
281 // not share a key. Workdir is the directory where the commands of the agent
282 // run. Responsibility and Parent are set together for a child agent.
283 type AgentConfig struct {
284 Name string `json:"name"`
285 ID string `json:"id"`
286 Model string `json:"model"`
287 Effort string `json:"effort"`
288 Workdir string `json:"workdir"`
289 Responsibility string `json:"responsibility,omitempty"`
290 Parent string `json:"parent,omitempty"`
291 }
292
293 // validate makes sure that each setting has a valid value.
294 func (c AgentConfig) validate() error {
295 if c.Name == "" || strings.ContainsAny(c.Name, "\r\n") {
296 return errors.New("name must be one line of text")
297 }
298
299 id, err := hex.DecodeString(c.ID)
300 if err != nil || len(id) != agentIDBytes {
301 return fmt.Errorf("id must be %d hexadecimal digits", 2*agentIDBytes)
302 }
303
304 if c.Model == "" || c.Effort == "" {
305 return errors.New("agent needs a model and an effort")
306 }
307
308 if (strings.TrimSpace(c.Responsibility) == "") != (c.Parent == "") {
309 return errors.New("a child needs both responsibility and parent")
310 }
311
312 if strings.ContainsAny(c.Parent, "\r\n") {
313 return errors.New("parent must be one line of text")
314 }
315
316 if !filepath.IsAbs(c.Workdir) {
317 return fmt.Errorf("workdir %q is not an absolute path", c.Workdir)
318 }
319
320 return nil
321 }
322
323 // agentChildNameValid accepts one word of 2 to 32 ASCII letters with an initial
324 // capital letter. Child names describe their area.
325 func agentChildNameValid(name string) bool {
326 if len(name) < 2 || len(name) > 32 || name[0] < 'A' || name[0] > 'Z' {
327 return false
328 }
329
330 for i := 1; i < len(name); i++ {
331 letter := name[i]
332 if (letter < 'A' || letter > 'Z') && (letter < 'a' || letter > 'z') {
333 return false
334 }
335 }
336
337 return true
338 }
339
340 // createAgent writes the config.json of a new agent in dir with the defaults
341 // and a new ID.
342 func createAgent(dir string) (*Agent, error) {
343 config, err := defaultAgentConfig()
344 if err != nil {
345 return nil, err
346 }
347
348 return writeAgent(dir, config)
349 }
350
351 // writeAgent gives config a new ID and writes it in dir.
352 func writeAgent(dir string, config AgentConfig) (*Agent, error) {
353 id := make([]byte, agentIDBytes)
354 _, err := rand.Read(id)
355 if err != nil {
356 return nil, fmt.Errorf("make agent ID: %w", err)
357 }
358
359 config.ID = hex.EncodeToString(id)
360 data, err := json.MarshalIndent(config, "", " ")
361 if err != nil {
362 return nil, fmt.Errorf("encode agent config: %w", err)
363 }
364
365 err = writeFileAtomic(filepath.Join(dir, "config.json"), append(data, '\n'))
366 if err != nil {
367 return nil, fmt.Errorf("write agent config: %w", err)
368 }
369
370 return &Agent{
371 dir: dir,
372 config: config,
373 }, nil
374 }
375
376 // defaultAgentConfig returns the settings of a new agent, without an ID. The
377 // commands run in the home directory of the user.
378 func defaultAgentConfig() (AgentConfig, error) {
379 home, err := os.UserHomeDir()
380 if err != nil {
381 return AgentConfig{}, fmt.Errorf("find working directory: %w", err)
382 }
383
384 return AgentConfig{
385 Name: "OneChat",
386 Model: "gpt-6.1-sol",
387 Effort: "medium",
388 Workdir: home,
389 }, nil
390 }