22 // toolClipCharacters is the most characters of a tool output: its head
23 // and its tail, half each.
24 toolClipCharacters = 30000
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
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
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
41 // ToolChat gives the messages and the nodes that zoom and date read.
42 type ToolChat interface {
44 Message(id int) (Message, error)
45 Node(level, index int) (Node, bool, error)
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.
68 messages *DaemonMessages
69 children *DaemonChildren
74 // Call logs the call of the tool name with its JSON arguments, makes the log
75 // durable, runs the tool, and logs its clipped text. It returns that text or
76 // the text and image parts of a single-message zoom. Pixels never enter the
77 // log. A failed tool gives its error as the output. An error of Call itself is
78 // an error of the log. When ctx ends before the flush, the tool does not run.
79 func (b *Toolbox) Call(ctx context.Context, name, arguments string) (any, error) {
80 _, err := b.log.Append("tool", toolCallText(name, arguments))
82 return "", fmt.Errorf("append tool call: %w", err)
85 err = b.log.Flush(ctx)
86 if err != nil && ctx.Err() == nil {
87 return "", fmt.Errorf("flush tool call: %w", err)
90 output := "not run: the turn was interrupted"
92 output = toolClip(b.run(ctx, name, arguments))
101 result, resultErr = imageToolOutput(b, name, arguments, output)
104 if resultErr != nil {
105 output = "error: " + resultErr.Error()
109 _, err = b.log.Append("echo", output)
111 return "", fmt.Errorf("append tool result: %w", err)
117 // run runs one tool and returns its output, or its error as text.
118 func (b *Toolbox) run(ctx context.Context, name, arguments string) string {
126 var args ToolZoomArguments
128 err = toolDecodeArguments(arguments, &args)
131 case args.Name != nil:
132 output, err = b.zoomAgent(ctx, *args.Name)
134 output, err = b.zoom(args.ID, args.N)
137 var args ToolDateArguments
139 err = toolDecodeArguments(arguments, &args)
141 output, err = b.date(args.ID)
144 var args ToolShellArguments
146 err = toolDecodeArguments(arguments, &args)
148 output = b.shell(ctx, args.Command)
151 var args ToolPathArguments
153 err = toolDecodeArguments(arguments, &args)
155 output, err = b.read(args.Path)
158 var args ToolWriteArguments
160 err = toolDecodeArguments(arguments, &args)
162 output, err = b.write(args.Path, args.Text)
165 var args ToolEditArguments
167 err = toolDecodeArguments(arguments, &args)
169 output, err = b.edit(args.Path, args.Old, args.New)
172 var args ToolSpawnArguments
174 err = toolDecodeArguments(arguments, &args)
177 err = fmt.Errorf("unknown tool %q", name)
179 output, err = b.runs.Spawn(ctx, args.Tasks, args.Effort)
182 var args ToolAgentCreateArguments
184 err = toolDecodeArguments(arguments, &args)
186 case b.children == nil:
187 err = fmt.Errorf("unknown tool %q", name)
189 output, err = b.children.Create(args.Name, args.Responsibility)
191 case "route", "unroute", "routes":
192 var args ToolRouteArguments
196 err = toolDecodeArguments(arguments, &struct{}{})
198 var source ToolRouteSourceArguments
200 err = toolDecodeArguments(arguments, &source)
201 args.Source = source.Source
203 err = toolDecodeArguments(arguments, &args)
207 case b.routes == nil:
208 err = fmt.Errorf("unknown tool %q", name)
210 case name == "routes":
211 output = b.routes.List()
212 case name == "route" && (b.messages == nil || b.messages.Child(b.agentName, args.Agent) == nil):
213 err = errors.New("the agent must be a child of the root")
215 err = b.routes.Set(args.Source, args.Agent)
216 output = "Route removed."
218 output = "Route set."
222 var args ToolTellArguments
224 err = toolDecodeArguments(arguments, &args)
227 err = fmt.Errorf("unknown tool %q", name)
228 case err == nil && b.messages != nil && b.messages.Child(b.agentName, args.Name) != nil:
229 err = b.messages.Tell(b.agentName, args.Name, args.Message)
230 output = "Sent. " + args.Name + "'s reply will reach you as a message."
232 output, err = b.runs.Tell(ctx, args.Name, args.Message)
234 case "whatsapp_find":
235 var args ToolWhatsAppFindArguments
237 err = toolDecodeArguments(arguments, &args)
239 case b.whatsapp == nil:
240 err = fmt.Errorf("unknown tool %q", name)
242 output, err = b.whatsapp.Find(ctx, args.Name)
244 case "whatsapp_send":
245 var args ToolWhatsAppSendArguments
247 err = toolDecodeArguments(arguments, &args)
249 case b.whatsapp == nil:
250 err = fmt.Errorf("unknown tool %q", name)
252 output, err = b.whatsapp.Send(ctx, args.ID, args.Chat, args.Text)
255 var args ToolScheduleSetArguments
257 err = toolDecodeArguments(arguments, &args)
259 case b.schedules == nil:
260 err = fmt.Errorf("unknown tool %q", name)
262 output, err = b.schedules.Set(args.Text, args.At, args.Cron)
264 case "schedule_list":
265 err = toolDecodeArguments(arguments, &struct{}{})
267 case b.schedules == nil:
268 err = fmt.Errorf("unknown tool %q", name)
270 output = b.schedules.List()
272 case "schedule_remove":
273 var args ToolScheduleRemoveArguments
275 err = toolDecodeArguments(arguments, &args)
277 case b.schedules == nil:
278 err = fmt.Errorf("unknown tool %q", name)
280 output, err = b.schedules.Remove(args.ID)
283 err = fmt.Errorf("unknown tool %q", name)
287 return "error: " + err.Error()
293 // zoomAgent returns a child's view or the whole log of a subagent.
294 func (b *Toolbox) zoomAgent(ctx context.Context, name string) (string, error) {
295 if b.messages != nil {
296 child := b.messages.Child(b.agentName, name)
298 return strings.Join(child.memory.ViewLines(nil), ""), nil
302 subagent, err := b.subagents.Get(ctx, name)
307 return subagent.Show()
310 // zoom returns message id whole for n = 1, or else the two lines under the line
312 func (b *Toolbox) zoom(id, n int) (string, error) {
313 if n < 1 || bits.OnesCount(uint(n)) != 1 || id < 0 || id%n != 0 {
314 return "", fmt.Errorf("line %d+%d does not exist: n must be a power of 2 and id a multiple of n", id, n)
317 if id+n > b.chat.Len() {
318 return "", fmt.Errorf("line %d+%d goes past the last message, %d", id, n, b.chat.Len()-1)
322 message, err := b.chat.Message(id)
324 return "", fmt.Errorf("read zoom message: %w", err)
327 return leafSource(message), nil
330 level := bits.TrailingZeros(uint(n)) - 1
334 for _, index := range []int{2 * id / n, 2*id/n + 1} {
335 node, built, err := b.chat.Node(level, index)
337 return "", fmt.Errorf("read zoom node: %w", err)
342 text = strings.ReplaceAll(node.Text, "\n", " ")
345 lines = append(lines, fmt.Sprintf("%d+%d|%s", index<<level, 1<<level, text))
348 return strings.Join(lines, "\n"), nil
351 // date returns the date and time of message id, with its weekday.
352 func (b *Toolbox) date(id int) (string, error) {
353 if id < 0 || id >= b.chat.Len() {
354 return "", fmt.Errorf("message %d does not exist", id)
357 message, err := b.chat.Message(id)
359 return "", fmt.Errorf("read dated message: %w", err)
362 date, err := time.Parse(time.RFC3339Nano, message.Date)
364 return "", fmt.Errorf("parse message date: %w", err)
367 return date.Local().Format("Monday 2006-01-02 15:04:05 -07:00"), nil
370 // shell runs command with bash in the working directory and returns its output
371 // and error output. A failed command adds its exit status. When ctx ends, the
372 // whole process group of the command is killed.
373 func (b *Toolbox) shell(ctx context.Context, command string) string {
374 cmd := exec.CommandContext(ctx, "bash", "-c", command)
376 cmd.SysProcAttr = &syscall.SysProcAttr{
379 cmd.Cancel = func() error {
380 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
382 cmd.WaitDelay = toolShellOutputTimeout
384 output := &ToolHeadTail{}
389 text := output.String()
391 var exit *exec.ExitError
394 case ctx.Err() != nil:
395 text += "\n[interrupted]"
396 case errors.As(err, &exit):
397 text += "\n[" + exit.String() + "]"
399 text += "\n[error: " + err.Error() + "]"
407 // read returns the text of the file at path.
408 func (b *Toolbox) read(path string) (text string, err error) {
409 file, err := os.Open(b.path(path))
411 return "", fmt.Errorf("open tool file: %w", err)
415 err = errors.Join(err, file.Close())
418 output := &ToolHeadTail{}
419 _, err = io.Copy(output, file)
421 return "", fmt.Errorf("read tool file: %w", err)
424 return output.String(), nil
427 // write replaces the file at path with text, and makes its missing
429 func (b *Toolbox) write(path, text string) (string, error) {
431 err := os.MkdirAll(filepath.Dir(path), 0o755)
433 return "", fmt.Errorf("create tool file directory: %w", err)
436 err = os.WriteFile(path, []byte(text), 0o644)
438 return "", fmt.Errorf("write tool file: %w", err)
441 return fmt.Sprintf("wrote %d bytes to %s", len(text), path), nil
444 // edit replaces the one occurrence of old in the file at path with
445 // replacement. It keeps the mode of the file.
446 func (b *Toolbox) edit(path, old, replacement string) (string, error) {
448 data, err := os.ReadFile(path)
450 return "", fmt.Errorf("read file to edit: %w", err)
454 return "", errors.New("old is empty")
457 count := strings.Count(string(data), old)
459 return "", fmt.Errorf("old occurs %d times in %s, not once", count, path)
462 info, err := os.Stat(path)
464 return "", fmt.Errorf("read file mode: %w", err)
467 err = os.WriteFile(path, []byte(strings.Replace(string(data), old, replacement, 1)), info.Mode().Perm())
469 return "", fmt.Errorf("write edited file: %w", err)
472 return "edited " + path, nil
475 // path returns path, relative to the working directory when it is not
477 func (b *Toolbox) path(path string) string {
478 if filepath.IsAbs(path) {
482 return filepath.Join(b.workdir, path)
485 // ToolZoomArguments are the arguments of zoom. A Name that is not nil names the
486 // child whose view or the subagent whose whole log zoom gives. ID and N are
488 type ToolZoomArguments struct {
491 Name *string `json:"name"`
494 // ToolDateArguments are the arguments of date.
495 type ToolDateArguments struct {
499 // ToolShellArguments are the arguments of shell.
500 type ToolShellArguments struct {
501 Command string `json:"command"`
504 // ToolPathArguments are the arguments of read.
505 type ToolPathArguments struct {
506 Path string `json:"path"`
509 // ToolWriteArguments are the arguments of write.
510 type ToolWriteArguments struct {
511 Path string `json:"path"`
512 Text string `json:"text"`
515 // ToolEditArguments are the arguments of edit.
516 type ToolEditArguments struct {
517 Path string `json:"path"`
518 Old string `json:"old"`
519 New string `json:"new"`
522 // ToolSpawnArguments are the arguments of spawn. A nil Effort is the effort of
524 type ToolSpawnArguments struct {
525 Tasks []string `json:"tasks"`
526 Effort *string `json:"effort"`
529 // ToolAgentCreateArguments are the arguments of agent_create.
530 type ToolAgentCreateArguments struct {
531 Name string `json:"name"`
532 Responsibility string `json:"responsibility"`
535 // ToolRouteArguments are the arguments of route.
536 type ToolRouteArguments struct {
537 Source string `json:"source"`
538 Agent string `json:"agent"`
541 // ToolRouteSourceArguments are the arguments of unroute.
542 type ToolRouteSourceArguments struct {
543 Source string `json:"source"`
546 // ToolTellArguments are the arguments of tell.
547 type ToolTellArguments struct {
548 Name string `json:"name"`
549 Message string `json:"message"`
552 // ToolWhatsAppFindArguments are the arguments of whatsapp_find.
553 type ToolWhatsAppFindArguments struct {
554 Name string `json:"name"`
557 // ToolWhatsAppSendArguments are the arguments of whatsapp_send.
558 type ToolWhatsAppSendArguments struct {
559 ID string `json:"id"`
560 Chat string `json:"chat"`
561 Text string `json:"text"`
564 // ToolScheduleSetArguments are the arguments of schedule_set. Exactly one of At
565 // and Cron is not nil.
566 type ToolScheduleSetArguments struct {
567 Text string `json:"text"`
568 At *string `json:"at"`
569 Cron *string `json:"cron"`
572 // ToolScheduleRemoveArguments are the arguments of schedule_remove.
573 type ToolScheduleRemoveArguments struct {
577 // ToolHeadTail keeps the first and the last toolClipBytes bytes written to it,
578 // and counts the bytes between them that it drops.
579 type ToolHeadTail struct {
585 // String returns the output, clipped when it is too long. Invalid UTF-8 becomes
586 // the replacement character.
587 func (h *ToolHeadTail) String() string {
588 replacement := string(utf8.RuneError)
590 return toolClip(strings.ToValidUTF8(string(h.head)+string(h.tail), replacement))
593 head := toolFirstCharacters(strings.ToValidUTF8(string(h.head), replacement), toolClipCharacters/2)
594 tail := toolLastCharacters(strings.ToValidUTF8(string(h.tail), replacement), toolClipCharacters/2)
595 cut := len(h.head) + h.dropped + len(h.tail) - len(head) - len(tail)
597 return toolJoinClipped(head, tail, cut)
600 // Write keeps the bytes of p at the ends of the output.
601 func (h *ToolHeadTail) Write(p []byte) (int, error) {
603 room := min(toolClipBytes-len(h.head), len(p))
604 h.head = append(h.head, p[:room]...)
607 h.tail = append(h.tail, p...)
608 if len(h.tail) > toolClipBytes {
609 cut := len(h.tail) - toolClipBytes
611 h.tail = bytes.Clone(h.tail[cut:])
617 // toolCallText returns the log text of a tool call: the name and the JSON
619 func toolCallText(name, arguments string) string {
620 return name + " " + arguments
623 // toolClip returns text when it has at most toolClipCharacters characters.
624 // Otherwise it returns the first and the last toolClipCharacters/2 characters,
625 // with a mark of the cut between them.
626 func toolClip(text string) string {
627 if utf8.RuneCountInString(text) <= toolClipCharacters {
631 head := toolFirstCharacters(text, toolClipCharacters/2)
632 tail := toolLastCharacters(text, toolClipCharacters/2)
634 return toolJoinClipped(head, tail, len(text)-len(head)-len(tail))
637 // toolDecodeArguments decodes the JSON arguments of a call into args. An
638 // unknown field is an error.
639 func toolDecodeArguments(arguments string, args any) error {
640 decoder := json.NewDecoder(strings.NewReader(arguments))
641 decoder.DisallowUnknownFields()
643 err := decoder.Decode(args)
645 return fmt.Errorf("arguments: %w", err)
651 // toolFirstCharacters returns the first count characters of text.
652 func toolFirstCharacters(text string, count int) string {
655 if end >= len(text) {
659 _, size := utf8.DecodeRuneInString(text[end:])
666 // toolLastCharacters returns the last count characters of text.
667 func toolLastCharacters(text string, count int) string {
674 _, size := utf8.DecodeLastRuneInString(text[:start])
681 // toolJoinClipped joins the head and the tail of a clipped output with a mark
683 func toolJoinClipped(head, tail string, cut int) string {
684 return head + "\n[... " + strconv.Itoa(cut) + " bytes cut ...]\n" + tail