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