]> git.giacomolli.uk - onechat/blobdiff - tools.go
Import WhatsApp history into a child
[onechat] / tools.go
index 73c584ae19c5a41bbd66642879e05e78b31ab39b..a75514f984964224f0e1075d06281cda3be79cb0 100644 (file)
--- a/tools.go
+++ b/tools.go
@@ -19,146 +19,250 @@ import (
 )
 
 const (
-       // clipCharacters is the most characters of a tool output: its head and
-       // its tail, half each.
-       clipCharacters = 30000
+       // toolClipCharacters is the most characters of a tool output: its head
+       // and its tail, half each.
+       toolClipCharacters = 30000
 
-       // clipBytes is the most bytes that a tool keeps of each end of an
-       // output, enough for clipCharacters/2 characters of 4 bytes.
-       clipBytes = 4 * clipCharacters / 2
+       // toolClipBytes is the most bytes that a tool keeps of each end of an
+       // output, enough for toolClipCharacters/2 characters of 4 bytes.
+       toolClipBytes = 4 * toolClipCharacters / 2
 
-       // shellWaitDelay is how long a command can keep its output open after it
-       // ends or after its kill.
-       shellWaitDelay = 2 * time.Second
+       // toolShellWaitDelay is how long a command can keep its output open
+       // after it ends or after its kill.
+       toolShellWaitDelay = 2 * time.Second
 )
 
-// chatLog is the log that the tools write their calls and results to.
-type chatLog interface {
+// ToolLog is the log that the tools write their calls and results to.
+type ToolLog interface {
        Append(kind, text string) (Message, error)
        Flush(ctx context.Context) error
 }
 
-// chatReader gives the messages and the nodes that zoom and date read.
-type chatReader interface {
+// ToolChat gives the messages and the nodes that zoom and date read.
+type ToolChat interface {
        Len() int
        Message(id int) (Message, error)
        Node(level, index int) (Node, bool, error)
 }
 
-// toolbox runs the tool calls of one agent. Each call and each result goes to
+// Toolbox runs the tool calls of one agent. Each call and each result goes to
 // the log as it happens: the call as a tool message, the result as an echo
-// message. The call is durable before its tool runs, so the log never misses
-// an action outside the agent. Commands and files run as the user in the
-// working directory, with no sandbox.
-type toolbox struct {
-       log     chatLog
-       chat    chatReader
-       workdir string
-}
-
-// shellArguments are the arguments of shell.
-type shellArguments struct {
-       Command string `json:"command"`
-}
-
-// pathArguments are the arguments of read.
-type pathArguments struct {
-       Path string `json:"path"`
-}
-
-// writeArguments are the arguments of write.
-type writeArguments struct {
-       Path string `json:"path"`
-       Text string `json:"text"`
-}
-
-// editArguments are the arguments of edit.
-type editArguments struct {
-       Path string `json:"path"`
-       Old  string `json:"old"`
-       New  string `json:"new"`
-}
-
-// zoomArguments are the arguments of zoom.
-type zoomArguments struct {
-       ID int `json:"id"`
-       N  int `json:"n"`
-}
-
-// dateArguments are the arguments of date.
-type dateArguments struct {
-       ID int `json:"id"`
+// message. The call is durable before its tool runs, so the log never misses an
+// action outside the agent. Commands and files run as the user in the working
+// directory, with no sandbox. Zoom with a name reads a child's view or a
+// subagent's log. The toolbox of the agent has the runs of its subagents for
+// spawn and tell, the WhatsApp link for the WhatsApp tools, and the schedules
+// for the schedule tools. Each permanent agent has the parent-child queue. Only
+// the root has child creation and source routes. A child has neither of them
+// nor a WhatsApp link. The toolbox of a subagent has none of them, so these are
+// unknown tools there.
+type Toolbox struct {
+       log       ToolLog
+       chat      ToolChat
+       subagents *Subagents
+       runs      *SubagentRuns
+       whatsapp  *WhatsApp
+       schedules *Schedules
+       workdir   string
+       agentName string
+       messages  *DaemonMessages
+       children  *DaemonChildren
+       routes    *Routes
 }
 
 // Call logs the call of the tool name with its JSON arguments, makes the log
 // durable, runs the tool, and logs its clipped output, which it returns. A
 // failed tool gives its error as the output. An error of Call itself is an
 // error of the log. When ctx ends before the flush, the tool does not run.
-func (b *toolbox) Call(ctx context.Context, name, arguments string) (string, error) {
-       _, err := b.log.Append("tool", callText(name, arguments))
+func (b *Toolbox) Call(ctx context.Context, name, arguments string) (string, error) {
+       _, err := b.log.Append("tool", toolCallText(name, arguments))
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("append tool call: %w", err)
        }
 
        err = b.log.Flush(ctx)
        if err != nil && ctx.Err() == nil {
-               return "", err
+               return "", fmt.Errorf("flush tool call: %w", err)
        }
 
        output := "not run: the turn was interrupted"
        if err == nil {
-               output = clip(b.run(ctx, name, arguments))
+               output = toolClip(b.run(ctx, name, arguments))
        }
 
        _, err = b.log.Append("echo", output)
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("append tool result: %w", err)
        }
 
        return output, nil
 }
 
 // run runs one tool and returns its output, or its error as text.
-func (b *toolbox) run(ctx context.Context, name, arguments string) string {
-       var output string
-       var err error
+func (b *Toolbox) run(ctx context.Context, name, arguments string) string {
+       var (
+               output string
+               err    error
+       )
+
        switch name {
        case "zoom":
-               var args zoomArguments
-               err = decodeArguments(arguments, &args)
-               if err == nil {
+               var args ToolZoomArguments
+
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case err != nil:
+               case args.Name != nil:
+                       output, err = b.zoomAgent(ctx, *args.Name)
+               default:
                        output, err = b.zoom(args.ID, args.N)
                }
        case "date":
-               var args dateArguments
-               err = decodeArguments(arguments, &args)
+               var args ToolDateArguments
+
+               err = toolDecodeArguments(arguments, &args)
                if err == nil {
                        output, err = b.date(args.ID)
                }
        case "shell":
-               var args shellArguments
-               err = decodeArguments(arguments, &args)
+               var args ToolShellArguments
+
+               err = toolDecodeArguments(arguments, &args)
                if err == nil {
                        output = b.shell(ctx, args.Command)
                }
        case "read":
-               var args pathArguments
-               err = decodeArguments(arguments, &args)
+               var args ToolPathArguments
+
+               err = toolDecodeArguments(arguments, &args)
                if err == nil {
                        output, err = b.read(args.Path)
                }
        case "write":
-               var args writeArguments
-               err = decodeArguments(arguments, &args)
+               var args ToolWriteArguments
+
+               err = toolDecodeArguments(arguments, &args)
                if err == nil {
                        output, err = b.write(args.Path, args.Text)
                }
        case "edit":
-               var args editArguments
-               err = decodeArguments(arguments, &args)
+               var args ToolEditArguments
+
+               err = toolDecodeArguments(arguments, &args)
                if err == nil {
                        output, err = b.edit(args.Path, args.Old, args.New)
                }
+       case "spawn":
+               var args ToolSpawnArguments
+
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case b.runs == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output, err = b.runs.Spawn(ctx, args.Tasks, args.Effort)
+               }
+       case "agent_create":
+               var args ToolAgentCreateArguments
+
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case b.children == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output, err = b.children.Create(args.Name, args.Responsibility)
+               }
+       case "route", "unroute", "routes":
+               var args ToolRouteArguments
+
+               switch name {
+               case "routes":
+                       err = toolDecodeArguments(arguments, &struct{}{})
+               case "unroute":
+                       var source ToolRouteSourceArguments
+
+                       err = toolDecodeArguments(arguments, &source)
+                       args.Source = source.Source
+               default:
+                       err = toolDecodeArguments(arguments, &args)
+               }
+
+               switch {
+               case b.routes == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err != nil:
+               case name == "routes":
+                       output = b.routes.List()
+               case name == "route" && (b.messages == nil || b.messages.Child(b.agentName, args.Agent) == nil):
+                       err = errors.New("the agent must be a child of the root")
+               default:
+                       err = b.routes.Set(args.Source, args.Agent)
+                       output = "Route removed."
+                       if name == "route" {
+                               output = "Route set."
+                       }
+               }
+       case "tell":
+               var args ToolTellArguments
+
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case b.runs == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil && b.messages != nil && b.messages.Child(b.agentName, args.Name) != nil:
+                       err = b.messages.Tell(b.agentName, args.Name, args.Message)
+                       output = "Sent. " + args.Name + "'s reply will reach you as a message."
+               case err == nil:
+                       output, err = b.runs.Tell(ctx, args.Name, args.Message)
+               }
+       case "whatsapp_find":
+               var args ToolWhatsAppFindArguments
+
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case b.whatsapp == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output, err = b.whatsapp.Find(ctx, args.Name)
+               }
+       case "whatsapp_send":
+               var args ToolWhatsAppSendArguments
+
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case b.whatsapp == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output, err = b.whatsapp.Send(ctx, args.ID, args.Chat, args.Text)
+               }
+       case "schedule_set":
+               var args ToolScheduleSetArguments
+
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case b.schedules == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output, err = b.schedules.Set(args.Text, args.At, args.Cron)
+               }
+       case "schedule_list":
+               err = toolDecodeArguments(arguments, &struct{}{})
+               switch {
+               case b.schedules == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output = b.schedules.List()
+               }
+       case "schedule_remove":
+               var args ToolScheduleRemoveArguments
+
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case b.schedules == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output, err = b.schedules.Remove(args.ID)
+               }
        default:
                err = fmt.Errorf("unknown tool %q", name)
        }
@@ -170,9 +274,26 @@ func (b *toolbox) run(ctx context.Context, name, arguments string) string {
        return output
 }
 
+// zoomAgent returns a child's view or the whole log of a subagent.
+func (b *Toolbox) zoomAgent(ctx context.Context, name string) (string, error) {
+       if b.messages != nil {
+               child := b.messages.Child(b.agentName, name)
+               if child != nil {
+                       return strings.Join(child.memory.ViewLines(nil), ""), nil
+               }
+       }
+
+       subagent, err := b.subagents.Get(ctx, name)
+       if err != nil {
+               return "", err
+       }
+
+       return subagent.Show()
+}
+
 // zoom returns message id whole for n = 1, or else the two lines under the line
 // id+n.
-func (b *toolbox) zoom(id, n int) (string, error) {
+func (b *Toolbox) zoom(id, n int) (string, error) {
        if n < 1 || bits.OnesCount(uint(n)) != 1 || id < 0 || id%n != 0 {
                return "", fmt.Errorf("line %d+%d does not exist: n must be a power of 2 and id a multiple of n", id, n)
        }
@@ -184,21 +305,23 @@ func (b *toolbox) zoom(id, n int) (string, error) {
        if n == 1 {
                message, err := b.chat.Message(id)
                if err != nil {
-                       return "", err
+                       return "", fmt.Errorf("read zoom message: %w", err)
                }
 
                return leafSource(message), nil
        }
 
        level := bits.TrailingZeros(uint(n)) - 1
+
        var lines []string
+
        for _, index := range []int{2 * id / n, 2*id/n + 1} {
                node, built, err := b.chat.Node(level, index)
                if err != nil {
-                       return "", err
+                       return "", fmt.Errorf("read zoom node: %w", err)
                }
 
-               text := pending
+               text := viewPending
                if built {
                        text = strings.ReplaceAll(node.Text, "\n", " ")
                }
@@ -210,19 +333,19 @@ func (b *toolbox) zoom(id, n int) (string, error) {
 }
 
 // date returns the date and time of message id, with its weekday.
-func (b *toolbox) date(id int) (string, error) {
+func (b *Toolbox) date(id int) (string, error) {
        if id < 0 || id >= b.chat.Len() {
                return "", fmt.Errorf("message %d does not exist", id)
        }
 
        message, err := b.chat.Message(id)
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("read dated message: %w", err)
        }
 
        date, err := time.Parse(time.RFC3339Nano, message.Date)
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("parse message date: %w", err)
        }
 
        return date.Local().Format("Monday 2006-01-02 15:04:05 -07:00"), nil
@@ -231,7 +354,7 @@ func (b *toolbox) date(id int) (string, error) {
 // shell runs command with bash in the working directory and returns its output
 // and error output. A failed command adds its exit status. When ctx ends, the
 // whole process group of the command is killed.
-func (b *toolbox) shell(ctx context.Context, command string) string {
+func (b *Toolbox) shell(ctx context.Context, command string) string {
        cmd := exec.CommandContext(ctx, "bash", "-c", command)
        cmd.Dir = b.workdir
        cmd.SysProcAttr = &syscall.SysProcAttr{
@@ -240,15 +363,17 @@ func (b *toolbox) shell(ctx context.Context, command string) string {
        cmd.Cancel = func() error {
                return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
        }
-       cmd.WaitDelay = shellWaitDelay
+       cmd.WaitDelay = toolShellWaitDelay
 
-       output := &headTail{}
+       output := &ToolHeadTail{}
        cmd.Stdout = output
        cmd.Stderr = output
        err := cmd.Run()
 
        text := output.String()
+
        var exit *exec.ExitError
+
        switch {
        case ctx.Err() != nil:
                text += "\n[interrupted]"
@@ -264,20 +389,20 @@ func (b *toolbox) shell(ctx context.Context, command string) string {
 }
 
 // read returns the text of the file at path.
-func (b *toolbox) read(path string) (text string, err error) {
+func (b *Toolbox) read(path string) (text string, err error) {
        file, err := os.Open(b.path(path))
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("open tool file: %w", err)
        }
 
        defer func() {
                err = errors.Join(err, file.Close())
        }()
 
-       output := &headTail{}
+       output := &ToolHeadTail{}
        _, err = io.Copy(output, file)
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("read tool file: %w", err)
        }
 
        return output.String(), nil
@@ -285,16 +410,16 @@ func (b *toolbox) read(path string) (text string, err error) {
 
 // write replaces the file at path with text, and makes its missing
 // directories.
-func (b *toolbox) write(path, text string) (string, error) {
+func (b *Toolbox) write(path, text string) (string, error) {
        path = b.path(path)
        err := os.MkdirAll(filepath.Dir(path), 0o755)
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("create tool file directory: %w", err)
        }
 
        err = os.WriteFile(path, []byte(text), 0o644)
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("write tool file: %w", err)
        }
 
        return fmt.Sprintf("wrote %d bytes to %s", len(text), path), nil
@@ -302,11 +427,11 @@ func (b *toolbox) write(path, text string) (string, error) {
 
 // edit replaces the one occurrence of old in the file at path with
 // replacement. It keeps the mode of the file.
-func (b *toolbox) edit(path, old, replacement string) (string, error) {
+func (b *Toolbox) edit(path, old, replacement string) (string, error) {
        path = b.path(path)
        data, err := os.ReadFile(path)
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("read file to edit: %w", err)
        }
 
        if old == "" {
@@ -320,12 +445,12 @@ func (b *toolbox) edit(path, old, replacement string) (string, error) {
 
        info, err := os.Stat(path)
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("read file mode: %w", err)
        }
 
        err = os.WriteFile(path, []byte(strings.Replace(string(data), old, replacement, 1)), info.Mode().Perm())
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("write edited file: %w", err)
        }
 
        return "edited " + path, nil
@@ -333,7 +458,7 @@ func (b *toolbox) edit(path, old, replacement string) (string, error) {
 
 // path returns path, relative to the working directory when it is not
 // absolute.
-func (b *toolbox) path(path string) string {
+func (b *Toolbox) path(path string) string {
        if filepath.IsAbs(path) {
                return path
        }
@@ -341,24 +466,131 @@ func (b *toolbox) path(path string) string {
        return filepath.Join(b.workdir, path)
 }
 
-// headTail keeps the first and the last clipBytes bytes written to it, and
-// counts the bytes between them that it drops.
-type headTail struct {
+// ToolZoomArguments are the arguments of zoom. A Name that is not nil names the
+// child whose view or the subagent whose whole log zoom gives. ID and N are
+// then not used.
+type ToolZoomArguments struct {
+       ID   int     `json:"id"`
+       N    int     `json:"n"`
+       Name *string `json:"name"`
+}
+
+// ToolDateArguments are the arguments of date.
+type ToolDateArguments struct {
+       ID int `json:"id"`
+}
+
+// ToolShellArguments are the arguments of shell.
+type ToolShellArguments struct {
+       Command string `json:"command"`
+}
+
+// ToolPathArguments are the arguments of read.
+type ToolPathArguments struct {
+       Path string `json:"path"`
+}
+
+// ToolWriteArguments are the arguments of write.
+type ToolWriteArguments struct {
+       Path string `json:"path"`
+       Text string `json:"text"`
+}
+
+// ToolEditArguments are the arguments of edit.
+type ToolEditArguments struct {
+       Path string `json:"path"`
+       Old  string `json:"old"`
+       New  string `json:"new"`
+}
+
+// ToolSpawnArguments are the arguments of spawn. A nil Effort is the effort of
+// the agent.
+type ToolSpawnArguments struct {
+       Tasks  []string `json:"tasks"`
+       Effort *string  `json:"effort"`
+}
+
+// ToolAgentCreateArguments are the arguments of agent_create.
+type ToolAgentCreateArguments struct {
+       Name           string `json:"name"`
+       Responsibility string `json:"responsibility"`
+}
+
+// ToolRouteArguments are the arguments of route.
+type ToolRouteArguments struct {
+       Source string `json:"source"`
+       Agent  string `json:"agent"`
+}
+
+// ToolRouteSourceArguments are the arguments of unroute.
+type ToolRouteSourceArguments struct {
+       Source string `json:"source"`
+}
+
+// ToolTellArguments are the arguments of tell.
+type ToolTellArguments struct {
+       Name    string `json:"name"`
+       Message string `json:"message"`
+}
+
+// ToolWhatsAppFindArguments are the arguments of whatsapp_find.
+type ToolWhatsAppFindArguments struct {
+       Name string `json:"name"`
+}
+
+// ToolWhatsAppSendArguments are the arguments of whatsapp_send.
+type ToolWhatsAppSendArguments struct {
+       ID   string `json:"id"`
+       Chat string `json:"chat"`
+       Text string `json:"text"`
+}
+
+// ToolScheduleSetArguments are the arguments of schedule_set. Exactly one of At
+// and Cron is not nil.
+type ToolScheduleSetArguments struct {
+       Text string  `json:"text"`
+       At   *string `json:"at"`
+       Cron *string `json:"cron"`
+}
+
+// ToolScheduleRemoveArguments are the arguments of schedule_remove.
+type ToolScheduleRemoveArguments struct {
+       ID int `json:"id"`
+}
+
+// ToolHeadTail keeps the first and the last toolClipBytes bytes written to it,
+// and counts the bytes between them that it drops.
+type ToolHeadTail struct {
        head    []byte
        tail    []byte
        dropped int
 }
 
+// String returns the output, clipped when it is too long. Invalid UTF-8 becomes
+// the replacement character.
+func (h *ToolHeadTail) String() string {
+       replacement := string(utf8.RuneError)
+       if h.dropped == 0 {
+               return toolClip(strings.ToValidUTF8(string(h.head)+string(h.tail), replacement))
+       }
+
+       head := toolFirstCharacters(strings.ToValidUTF8(string(h.head), replacement), toolClipCharacters/2)
+       tail := toolLastCharacters(strings.ToValidUTF8(string(h.tail), replacement), toolClipCharacters/2)
+       cut := len(h.head) + h.dropped + len(h.tail) - len(head) - len(tail)
+
+       return toolJoinClipped(head, tail, cut)
+}
+
 // Write keeps the bytes of p at the ends of the output.
-func (h *headTail) Write(p []byte) (int, error) {
+func (h *ToolHeadTail) Write(p []byte) (int, error) {
        n := len(p)
-       room := min(clipBytes-len(h.head), len(p))
+       room := min(toolClipBytes-len(h.head), len(p))
        h.head = append(h.head, p[:room]...)
        p = p[room:]
 
        h.tail = append(h.tail, p...)
-       if len(h.tail) > clipBytes {
-               cut := len(h.tail) - clipBytes
+       if len(h.tail) > toolClipBytes {
+               cut := len(h.tail) - toolClipBytes
                h.dropped += cut
                h.tail = bytes.Clone(h.tail[cut:])
        }
@@ -366,32 +598,32 @@ func (h *headTail) Write(p []byte) (int, error) {
        return n, nil
 }
 
-// String returns the output, clipped when it is too long. Invalid UTF-8 becomes
-// the replacement character.
-func (h *headTail) String() string {
-       replacement := string(utf8.RuneError)
-       if h.dropped == 0 {
-               return clip(strings.ToValidUTF8(string(h.head)+string(h.tail), replacement))
-       }
+// toolCallText returns the log text of a tool call: the name and the JSON
+// arguments.
+func toolCallText(name, arguments string) string {
+       return name + " " + arguments
+}
 
-       head := firstCharacters(strings.ToValidUTF8(string(h.head), replacement), clipCharacters/2)
-       tail := lastCharacters(strings.ToValidUTF8(string(h.tail), replacement), clipCharacters/2)
-       cut := len(h.head) + h.dropped + len(h.tail) - len(head) - len(tail)
+// toolClip returns text when it has at most toolClipCharacters characters.
+// Otherwise it returns the first and the last toolClipCharacters/2 characters,
+// with a mark of the cut between them.
+func toolClip(text string) string {
+       if utf8.RuneCountInString(text) <= toolClipCharacters {
+               return text
+       }
 
-       return joinClipped(head, tail, cut)
-}
+       head := toolFirstCharacters(text, toolClipCharacters/2)
+       tail := toolLastCharacters(text, toolClipCharacters/2)
 
-// callText returns the log text of a tool call: the name and the JSON
-// arguments.
-func callText(name, arguments string) string {
-       return name + " " + arguments
+       return toolJoinClipped(head, tail, len(text)-len(head)-len(tail))
 }
 
-// decodeArguments decodes the JSON arguments of a call into args. An unknown
-// field is an error.
-func decodeArguments(arguments string, args any) error {
+// toolDecodeArguments decodes the JSON arguments of a call into args. An
+// unknown field is an error.
+func toolDecodeArguments(arguments string, args any) error {
        decoder := json.NewDecoder(strings.NewReader(arguments))
        decoder.DisallowUnknownFields()
+
        err := decoder.Decode(args)
        if err != nil {
                return fmt.Errorf("arguments: %w", err)
@@ -400,28 +632,8 @@ func decodeArguments(arguments string, args any) error {
        return nil
 }
 
-// clip returns text when it has at most clipCharacters characters. Otherwise it
-// returns the first and the last clipCharacters/2 characters, with a mark of
-// the cut between them.
-func clip(text string) string {
-       if utf8.RuneCountInString(text) <= clipCharacters {
-               return text
-       }
-
-       head := firstCharacters(text, clipCharacters/2)
-       tail := lastCharacters(text, clipCharacters/2)
-
-       return joinClipped(head, tail, len(text)-len(head)-len(tail))
-}
-
-// joinClipped joins the head and the tail of a clipped output with a mark of
-// the cut bytes.
-func joinClipped(head, tail string, cut int) string {
-       return head + "\n[... " + strconv.Itoa(cut) + " bytes cut ...]\n" + tail
-}
-
-// firstCharacters returns the first count characters of text.
-func firstCharacters(text string, count int) string {
+// toolFirstCharacters returns the first count characters of text.
+func toolFirstCharacters(text string, count int) string {
        end := 0
        for range count {
                if end >= len(text) {
@@ -435,8 +647,8 @@ func firstCharacters(text string, count int) string {
        return text[:end]
 }
 
-// lastCharacters returns the last count characters of text.
-func lastCharacters(text string, count int) string {
+// toolLastCharacters returns the last count characters of text.
+func toolLastCharacters(text string, count int) string {
        start := len(text)
        for range count {
                if start <= 0 {
@@ -449,3 +661,9 @@ func lastCharacters(text string, count int) string {
 
        return text[start:]
 }
+
+// toolJoinClipped joins the head and the tail of a clipped output with a mark
+// of the cut bytes.
+func toolJoinClipped(head, tail string, cut int) string {
+       return head + "\n[... " + strconv.Itoa(cut) + " bytes cut ...]\n" + tail
+}