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