X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/7b6d0a81e0f3c8e33b1b76d5ecbc5bdb25a87249..59e1537e1969f7c109084497287d729edbedba04:/tools.go diff --git a/tools.go b/tools.go index 7ca031c..4c00350 100644 --- a/tools.go +++ b/tools.go @@ -19,146 +19,201 @@ 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 the log of a subagent. 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. 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 } // 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 "tell": + var args ToolTellArguments + + err = toolDecodeArguments(arguments, &args) + switch { + case b.runs == nil: + err = fmt.Errorf("unknown tool %q", name) + 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 +225,19 @@ func (b *toolbox) run(ctx context.Context, name, arguments string) string { return output } +// zoomAgent returns the whole log of the subagent name. +func (b *Toolbox) zoomAgent(ctx context.Context, name string) (string, error) { + 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,18 +249,20 @@ 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 := viewPending @@ -210,19 +277,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 +298,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 +307,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 +333,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 +354,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 +371,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 +389,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 +402,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 +410,113 @@ 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 subagent whose whole log zoom gives, and then ID and N are 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"` +} + +// 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 +524,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 +558,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 +573,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 +587,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 +}