]> git.giacomolli.uk - onechat/blob - tools.go
Give the root tools to start and message children
[onechat] / tools.go
1 package main
2
3 import (
4         "bytes"
5         "context"
6         "encoding/json"
7         "errors"
8         "fmt"
9         "io"
10         "math/bits"
11         "os"
12         "os/exec"
13         "path/filepath"
14         "strconv"
15         "strings"
16         "syscall"
17         "time"
18         "unicode/utf8"
19 )
20
21 const (
22         // toolClipCharacters is the most characters of a tool output: its head
23         // and its tail, half each.
24         toolClipCharacters = 30000
25
26         // toolClipBytes is the most bytes that a tool keeps of each end of an
27         // output, enough for toolClipCharacters/2 characters of 4 bytes.
28         toolClipBytes = 4 * toolClipCharacters / 2
29
30         // toolShellWaitDelay is how long a command can keep its output open
31         // after it ends or after its kill.
32         toolShellWaitDelay = 2 * time.Second
33 )
34
35 // ToolLog is the log that the tools write their calls and results to.
36 type ToolLog interface {
37         Append(kind, text string) (Message, error)
38         Flush(ctx context.Context) error
39 }
40
41 // ToolChat gives the messages and the nodes that zoom and date read.
42 type ToolChat interface {
43         Len() int
44         Message(id int) (Message, error)
45         Node(level, index int) (Node, bool, error)
46 }
47
48 // Toolbox runs the tool calls of one agent. Each call and each result goes to
49 // the log as it happens: the call as a tool message, the result as an echo
50 // message. The call is durable before its tool runs, so the log never misses an
51 // action outside the agent. Commands and files run as the user in the working
52 // directory, with no sandbox. Zoom with a name reads a child's view or a
53 // subagent's log. The toolbox of the agent has the runs of its subagents for
54 // spawn and tell, the WhatsApp link for the WhatsApp tools, and the schedules
55 // for the schedule tools. Each permanent agent has the parent-child queue. Only
56 // the root has child creation. A child has no WhatsApp link or child creation.
57 // The toolbox of a subagent has none of them, so these are unknown tools there.
58 type Toolbox struct {
59         log       ToolLog
60         chat      ToolChat
61         subagents *Subagents
62         runs      *SubagentRuns
63         whatsapp  *WhatsApp
64         schedules *Schedules
65         workdir   string
66         agentName string
67         messages  *DaemonMessages
68         children  *DaemonChildren
69 }
70
71 // Call logs the call of the tool name with its JSON arguments, makes the log
72 // durable, runs the tool, and logs its clipped output, which it returns. A
73 // failed tool gives its error as the output. An error of Call itself is an
74 // error of the log. When ctx ends before the flush, the tool does not run.
75 func (b *Toolbox) Call(ctx context.Context, name, arguments string) (string, error) {
76         _, err := b.log.Append("tool", toolCallText(name, arguments))
77         if err != nil {
78                 return "", fmt.Errorf("append tool call: %w", err)
79         }
80
81         err = b.log.Flush(ctx)
82         if err != nil && ctx.Err() == nil {
83                 return "", fmt.Errorf("flush tool call: %w", err)
84         }
85
86         output := "not run: the turn was interrupted"
87         if err == nil {
88                 output = toolClip(b.run(ctx, name, arguments))
89         }
90
91         _, err = b.log.Append("echo", output)
92         if err != nil {
93                 return "", fmt.Errorf("append tool result: %w", err)
94         }
95
96         return output, nil
97 }
98
99 // run runs one tool and returns its output, or its error as text.
100 func (b *Toolbox) run(ctx context.Context, name, arguments string) string {
101         var (
102                 output string
103                 err    error
104         )
105
106         switch name {
107         case "zoom":
108                 var args ToolZoomArguments
109
110                 err = toolDecodeArguments(arguments, &args)
111                 switch {
112                 case err != nil:
113                 case args.Name != nil:
114                         output, err = b.zoomAgent(ctx, *args.Name)
115                 default:
116                         output, err = b.zoom(args.ID, args.N)
117                 }
118         case "date":
119                 var args ToolDateArguments
120
121                 err = toolDecodeArguments(arguments, &args)
122                 if err == nil {
123                         output, err = b.date(args.ID)
124                 }
125         case "shell":
126                 var args ToolShellArguments
127
128                 err = toolDecodeArguments(arguments, &args)
129                 if err == nil {
130                         output = b.shell(ctx, args.Command)
131                 }
132         case "read":
133                 var args ToolPathArguments
134
135                 err = toolDecodeArguments(arguments, &args)
136                 if err == nil {
137                         output, err = b.read(args.Path)
138                 }
139         case "write":
140                 var args ToolWriteArguments
141
142                 err = toolDecodeArguments(arguments, &args)
143                 if err == nil {
144                         output, err = b.write(args.Path, args.Text)
145                 }
146         case "edit":
147                 var args ToolEditArguments
148
149                 err = toolDecodeArguments(arguments, &args)
150                 if err == nil {
151                         output, err = b.edit(args.Path, args.Old, args.New)
152                 }
153         case "spawn":
154                 var args ToolSpawnArguments
155
156                 err = toolDecodeArguments(arguments, &args)
157                 switch {
158                 case b.runs == nil:
159                         err = fmt.Errorf("unknown tool %q", name)
160                 case err == nil:
161                         output, err = b.runs.Spawn(ctx, args.Tasks, args.Effort)
162                 }
163         case "agent_create":
164                 var args ToolAgentCreateArguments
165
166                 err = toolDecodeArguments(arguments, &args)
167                 switch {
168                 case b.children == nil:
169                         err = fmt.Errorf("unknown tool %q", name)
170                 case err == nil:
171                         output, err = b.children.Create(args.Name, args.Responsibility)
172                 }
173         case "tell":
174                 var args ToolTellArguments
175
176                 err = toolDecodeArguments(arguments, &args)
177                 switch {
178                 case b.runs == nil:
179                         err = fmt.Errorf("unknown tool %q", name)
180                 case err == nil && b.messages != nil && b.messages.Child(b.agentName, args.Name) != nil:
181                         err = b.messages.Tell(b.agentName, args.Name, args.Message)
182                         output = "Sent. " + args.Name + "'s reply will reach you as a message."
183                 case err == nil:
184                         output, err = b.runs.Tell(ctx, args.Name, args.Message)
185                 }
186         case "whatsapp_find":
187                 var args ToolWhatsAppFindArguments
188
189                 err = toolDecodeArguments(arguments, &args)
190                 switch {
191                 case b.whatsapp == nil:
192                         err = fmt.Errorf("unknown tool %q", name)
193                 case err == nil:
194                         output, err = b.whatsapp.Find(ctx, args.Name)
195                 }
196         case "whatsapp_send":
197                 var args ToolWhatsAppSendArguments
198
199                 err = toolDecodeArguments(arguments, &args)
200                 switch {
201                 case b.whatsapp == nil:
202                         err = fmt.Errorf("unknown tool %q", name)
203                 case err == nil:
204                         output, err = b.whatsapp.Send(ctx, args.ID, args.Chat, args.Text)
205                 }
206         case "schedule_set":
207                 var args ToolScheduleSetArguments
208
209                 err = toolDecodeArguments(arguments, &args)
210                 switch {
211                 case b.schedules == nil:
212                         err = fmt.Errorf("unknown tool %q", name)
213                 case err == nil:
214                         output, err = b.schedules.Set(args.Text, args.At, args.Cron)
215                 }
216         case "schedule_list":
217                 err = toolDecodeArguments(arguments, &struct{}{})
218                 switch {
219                 case b.schedules == nil:
220                         err = fmt.Errorf("unknown tool %q", name)
221                 case err == nil:
222                         output = b.schedules.List()
223                 }
224         case "schedule_remove":
225                 var args ToolScheduleRemoveArguments
226
227                 err = toolDecodeArguments(arguments, &args)
228                 switch {
229                 case b.schedules == nil:
230                         err = fmt.Errorf("unknown tool %q", name)
231                 case err == nil:
232                         output, err = b.schedules.Remove(args.ID)
233                 }
234         default:
235                 err = fmt.Errorf("unknown tool %q", name)
236         }
237
238         if err != nil {
239                 return "error: " + err.Error()
240         }
241
242         return output
243 }
244
245 // zoomAgent returns a child's view or the whole log of a subagent.
246 func (b *Toolbox) zoomAgent(ctx context.Context, name string) (string, error) {
247         if b.messages != nil {
248                 child := b.messages.Child(b.agentName, name)
249                 if child != nil {
250                         return strings.Join(child.memory.ViewLines(nil), ""), nil
251                 }
252         }
253
254         subagent, err := b.subagents.Get(ctx, name)
255         if err != nil {
256                 return "", err
257         }
258
259         return subagent.Show()
260 }
261
262 // zoom returns message id whole for n = 1, or else the two lines under the line
263 // id+n.
264 func (b *Toolbox) zoom(id, n int) (string, error) {
265         if n < 1 || bits.OnesCount(uint(n)) != 1 || id < 0 || id%n != 0 {
266                 return "", fmt.Errorf("line %d+%d does not exist: n must be a power of 2 and id a multiple of n", id, n)
267         }
268
269         if id+n > b.chat.Len() {
270                 return "", fmt.Errorf("line %d+%d goes past the last message, %d", id, n, b.chat.Len()-1)
271         }
272
273         if n == 1 {
274                 message, err := b.chat.Message(id)
275                 if err != nil {
276                         return "", fmt.Errorf("read zoom message: %w", err)
277                 }
278
279                 return leafSource(message), nil
280         }
281
282         level := bits.TrailingZeros(uint(n)) - 1
283
284         var lines []string
285
286         for _, index := range []int{2 * id / n, 2*id/n + 1} {
287                 node, built, err := b.chat.Node(level, index)
288                 if err != nil {
289                         return "", fmt.Errorf("read zoom node: %w", err)
290                 }
291
292                 text := viewPending
293                 if built {
294                         text = strings.ReplaceAll(node.Text, "\n", " ")
295                 }
296
297                 lines = append(lines, fmt.Sprintf("%d+%d|%s", index<<level, 1<<level, text))
298         }
299
300         return strings.Join(lines, "\n"), nil
301 }
302
303 // date returns the date and time of message id, with its weekday.
304 func (b *Toolbox) date(id int) (string, error) {
305         if id < 0 || id >= b.chat.Len() {
306                 return "", fmt.Errorf("message %d does not exist", id)
307         }
308
309         message, err := b.chat.Message(id)
310         if err != nil {
311                 return "", fmt.Errorf("read dated message: %w", err)
312         }
313
314         date, err := time.Parse(time.RFC3339Nano, message.Date)
315         if err != nil {
316                 return "", fmt.Errorf("parse message date: %w", err)
317         }
318
319         return date.Local().Format("Monday 2006-01-02 15:04:05 -07:00"), nil
320 }
321
322 // shell runs command with bash in the working directory and returns its output
323 // and error output. A failed command adds its exit status. When ctx ends, the
324 // whole process group of the command is killed.
325 func (b *Toolbox) shell(ctx context.Context, command string) string {
326         cmd := exec.CommandContext(ctx, "bash", "-c", command)
327         cmd.Dir = b.workdir
328         cmd.SysProcAttr = &syscall.SysProcAttr{
329                 Setpgid: true,
330         }
331         cmd.Cancel = func() error {
332                 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
333         }
334         cmd.WaitDelay = toolShellWaitDelay
335
336         output := &ToolHeadTail{}
337         cmd.Stdout = output
338         cmd.Stderr = output
339         err := cmd.Run()
340
341         text := output.String()
342
343         var exit *exec.ExitError
344
345         switch {
346         case ctx.Err() != nil:
347                 text += "\n[interrupted]"
348         case errors.As(err, &exit):
349                 text += "\n[" + exit.String() + "]"
350         case err != nil:
351                 text += "\n[error: " + err.Error() + "]"
352         case text == "":
353                 text = "(no output)"
354         }
355
356         return text
357 }
358
359 // read returns the text of the file at path.
360 func (b *Toolbox) read(path string) (text string, err error) {
361         file, err := os.Open(b.path(path))
362         if err != nil {
363                 return "", fmt.Errorf("open tool file: %w", err)
364         }
365
366         defer func() {
367                 err = errors.Join(err, file.Close())
368         }()
369
370         output := &ToolHeadTail{}
371         _, err = io.Copy(output, file)
372         if err != nil {
373                 return "", fmt.Errorf("read tool file: %w", err)
374         }
375
376         return output.String(), nil
377 }
378
379 // write replaces the file at path with text, and makes its missing
380 // directories.
381 func (b *Toolbox) write(path, text string) (string, error) {
382         path = b.path(path)
383         err := os.MkdirAll(filepath.Dir(path), 0o755)
384         if err != nil {
385                 return "", fmt.Errorf("create tool file directory: %w", err)
386         }
387
388         err = os.WriteFile(path, []byte(text), 0o644)
389         if err != nil {
390                 return "", fmt.Errorf("write tool file: %w", err)
391         }
392
393         return fmt.Sprintf("wrote %d bytes to %s", len(text), path), nil
394 }
395
396 // edit replaces the one occurrence of old in the file at path with
397 // replacement. It keeps the mode of the file.
398 func (b *Toolbox) edit(path, old, replacement string) (string, error) {
399         path = b.path(path)
400         data, err := os.ReadFile(path)
401         if err != nil {
402                 return "", fmt.Errorf("read file to edit: %w", err)
403         }
404
405         if old == "" {
406                 return "", errors.New("old is empty")
407         }
408
409         count := strings.Count(string(data), old)
410         if count != 1 {
411                 return "", fmt.Errorf("old occurs %d times in %s, not once", count, path)
412         }
413
414         info, err := os.Stat(path)
415         if err != nil {
416                 return "", fmt.Errorf("read file mode: %w", err)
417         }
418
419         err = os.WriteFile(path, []byte(strings.Replace(string(data), old, replacement, 1)), info.Mode().Perm())
420         if err != nil {
421                 return "", fmt.Errorf("write edited file: %w", err)
422         }
423
424         return "edited " + path, nil
425 }
426
427 // path returns path, relative to the working directory when it is not
428 // absolute.
429 func (b *Toolbox) path(path string) string {
430         if filepath.IsAbs(path) {
431                 return path
432         }
433
434         return filepath.Join(b.workdir, path)
435 }
436
437 // ToolZoomArguments are the arguments of zoom. A Name that is not nil names the
438 // child whose view or the subagent whose whole log zoom gives. ID and N are
439 // then not used.
440 type ToolZoomArguments struct {
441         ID   int     `json:"id"`
442         N    int     `json:"n"`
443         Name *string `json:"name"`
444 }
445
446 // ToolDateArguments are the arguments of date.
447 type ToolDateArguments struct {
448         ID int `json:"id"`
449 }
450
451 // ToolShellArguments are the arguments of shell.
452 type ToolShellArguments struct {
453         Command string `json:"command"`
454 }
455
456 // ToolPathArguments are the arguments of read.
457 type ToolPathArguments struct {
458         Path string `json:"path"`
459 }
460
461 // ToolWriteArguments are the arguments of write.
462 type ToolWriteArguments struct {
463         Path string `json:"path"`
464         Text string `json:"text"`
465 }
466
467 // ToolEditArguments are the arguments of edit.
468 type ToolEditArguments struct {
469         Path string `json:"path"`
470         Old  string `json:"old"`
471         New  string `json:"new"`
472 }
473
474 // ToolSpawnArguments are the arguments of spawn. A nil Effort is the effort of
475 // the agent.
476 type ToolSpawnArguments struct {
477         Tasks  []string `json:"tasks"`
478         Effort *string  `json:"effort"`
479 }
480
481 // ToolAgentCreateArguments are the arguments of agent_create.
482 type ToolAgentCreateArguments struct {
483         Name           string `json:"name"`
484         Responsibility string `json:"responsibility"`
485 }
486
487 // ToolTellArguments are the arguments of tell.
488 type ToolTellArguments struct {
489         Name    string `json:"name"`
490         Message string `json:"message"`
491 }
492
493 // ToolWhatsAppFindArguments are the arguments of whatsapp_find.
494 type ToolWhatsAppFindArguments struct {
495         Name string `json:"name"`
496 }
497
498 // ToolWhatsAppSendArguments are the arguments of whatsapp_send.
499 type ToolWhatsAppSendArguments struct {
500         ID   string `json:"id"`
501         Chat string `json:"chat"`
502         Text string `json:"text"`
503 }
504
505 // ToolScheduleSetArguments are the arguments of schedule_set. Exactly one of At
506 // and Cron is not nil.
507 type ToolScheduleSetArguments struct {
508         Text string  `json:"text"`
509         At   *string `json:"at"`
510         Cron *string `json:"cron"`
511 }
512
513 // ToolScheduleRemoveArguments are the arguments of schedule_remove.
514 type ToolScheduleRemoveArguments struct {
515         ID int `json:"id"`
516 }
517
518 // ToolHeadTail keeps the first and the last toolClipBytes bytes written to it,
519 // and counts the bytes between them that it drops.
520 type ToolHeadTail struct {
521         head    []byte
522         tail    []byte
523         dropped int
524 }
525
526 // String returns the output, clipped when it is too long. Invalid UTF-8 becomes
527 // the replacement character.
528 func (h *ToolHeadTail) String() string {
529         replacement := string(utf8.RuneError)
530         if h.dropped == 0 {
531                 return toolClip(strings.ToValidUTF8(string(h.head)+string(h.tail), replacement))
532         }
533
534         head := toolFirstCharacters(strings.ToValidUTF8(string(h.head), replacement), toolClipCharacters/2)
535         tail := toolLastCharacters(strings.ToValidUTF8(string(h.tail), replacement), toolClipCharacters/2)
536         cut := len(h.head) + h.dropped + len(h.tail) - len(head) - len(tail)
537
538         return toolJoinClipped(head, tail, cut)
539 }
540
541 // Write keeps the bytes of p at the ends of the output.
542 func (h *ToolHeadTail) Write(p []byte) (int, error) {
543         n := len(p)
544         room := min(toolClipBytes-len(h.head), len(p))
545         h.head = append(h.head, p[:room]...)
546         p = p[room:]
547
548         h.tail = append(h.tail, p...)
549         if len(h.tail) > toolClipBytes {
550                 cut := len(h.tail) - toolClipBytes
551                 h.dropped += cut
552                 h.tail = bytes.Clone(h.tail[cut:])
553         }
554
555         return n, nil
556 }
557
558 // toolCallText returns the log text of a tool call: the name and the JSON
559 // arguments.
560 func toolCallText(name, arguments string) string {
561         return name + " " + arguments
562 }
563
564 // toolClip returns text when it has at most toolClipCharacters characters.
565 // Otherwise it returns the first and the last toolClipCharacters/2 characters,
566 // with a mark of the cut between them.
567 func toolClip(text string) string {
568         if utf8.RuneCountInString(text) <= toolClipCharacters {
569                 return text
570         }
571
572         head := toolFirstCharacters(text, toolClipCharacters/2)
573         tail := toolLastCharacters(text, toolClipCharacters/2)
574
575         return toolJoinClipped(head, tail, len(text)-len(head)-len(tail))
576 }
577
578 // toolDecodeArguments decodes the JSON arguments of a call into args. An
579 // unknown field is an error.
580 func toolDecodeArguments(arguments string, args any) error {
581         decoder := json.NewDecoder(strings.NewReader(arguments))
582         decoder.DisallowUnknownFields()
583
584         err := decoder.Decode(args)
585         if err != nil {
586                 return fmt.Errorf("arguments: %w", err)
587         }
588
589         return nil
590 }
591
592 // toolFirstCharacters returns the first count characters of text.
593 func toolFirstCharacters(text string, count int) string {
594         end := 0
595         for range count {
596                 if end >= len(text) {
597                         break
598                 }
599
600                 _, size := utf8.DecodeRuneInString(text[end:])
601                 end += size
602         }
603
604         return text[:end]
605 }
606
607 // toolLastCharacters returns the last count characters of text.
608 func toolLastCharacters(text string, count int) string {
609         start := len(text)
610         for range count {
611                 if start <= 0 {
612                         break
613                 }
614
615                 _, size := utf8.DecodeLastRuneInString(text[:start])
616                 start -= size
617         }
618
619         return text[start:]
620 }
621
622 // toolJoinClipped joins the head and the tail of a clipped output with a mark
623 // of the cut bytes.
624 func toolJoinClipped(head, tail string, cut int) string {
625         return head + "\n[... " + strconv.Itoa(cut) + " bytes cut ...]\n" + tail
626 }