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