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