]> git.giacomolli.uk - onechat/blob - tools.go
b20b3ace6f22bf38b40b4681488e584cdc62c04e
[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 the log of a subagent.
53 // The toolbox of the agent has the runs of its subagents for spawn and tell,
54 // and the WhatsApp link for the WhatsApp tools. The toolbox of a subagent has
55 // neither, so these are unknown tools there.
56 type Toolbox struct {
57 log ToolLog
58 chat ToolChat
59 subagents *Subagents
60 runs *SubagentRuns
61 whatsapp *WhatsApp
62 workdir string
63 }
64
65 // Call logs the call of the tool name with its JSON arguments, makes the log
66 // durable, runs the tool, and logs its clipped output, which it returns. A
67 // failed tool gives its error as the output. An error of Call itself is an
68 // error of the log. When ctx ends before the flush, the tool does not run.
69 func (b *Toolbox) Call(ctx context.Context, name, arguments string) (string, error) {
70 _, err := b.log.Append("tool", toolCallText(name, arguments))
71 if err != nil {
72 return "", err
73 }
74
75 err = b.log.Flush(ctx)
76 if err != nil && ctx.Err() == nil {
77 return "", err
78 }
79
80 output := "not run: the turn was interrupted"
81 if err == nil {
82 output = toolClip(b.run(ctx, name, arguments))
83 }
84
85 _, err = b.log.Append("echo", output)
86 if err != nil {
87 return "", err
88 }
89
90 return output, nil
91 }
92
93 // run runs one tool and returns its output, or its error as text.
94 func (b *Toolbox) run(ctx context.Context, name, arguments string) string {
95 var output string
96 var err error
97 switch name {
98 case "zoom":
99 var args ToolZoomArguments
100 err = toolDecodeArguments(arguments, &args)
101 switch {
102 case err != nil:
103 case args.Name != nil:
104 output, err = b.zoomAgent(ctx, *args.Name)
105 default:
106 output, err = b.zoom(args.ID, args.N)
107 }
108 case "date":
109 var args ToolDateArguments
110 err = toolDecodeArguments(arguments, &args)
111 if err == nil {
112 output, err = b.date(args.ID)
113 }
114 case "shell":
115 var args ToolShellArguments
116 err = toolDecodeArguments(arguments, &args)
117 if err == nil {
118 output = b.shell(ctx, args.Command)
119 }
120 case "read":
121 var args ToolPathArguments
122 err = toolDecodeArguments(arguments, &args)
123 if err == nil {
124 output, err = b.read(args.Path)
125 }
126 case "write":
127 var args ToolWriteArguments
128 err = toolDecodeArguments(arguments, &args)
129 if err == nil {
130 output, err = b.write(args.Path, args.Text)
131 }
132 case "edit":
133 var args ToolEditArguments
134 err = toolDecodeArguments(arguments, &args)
135 if err == nil {
136 output, err = b.edit(args.Path, args.Old, args.New)
137 }
138 case "spawn":
139 var args ToolSpawnArguments
140 err = toolDecodeArguments(arguments, &args)
141 switch {
142 case b.runs == nil:
143 err = fmt.Errorf("unknown tool %q", name)
144 case err == nil:
145 output, err = b.runs.Spawn(ctx, args.Tasks, args.Effort)
146 }
147 case "tell":
148 var args ToolTellArguments
149 err = toolDecodeArguments(arguments, &args)
150 switch {
151 case b.runs == nil:
152 err = fmt.Errorf("unknown tool %q", name)
153 case err == nil:
154 output, err = b.runs.Tell(ctx, args.Name, args.Message)
155 }
156 case "whatsapp_find":
157 var args ToolWhatsAppFindArguments
158 err = toolDecodeArguments(arguments, &args)
159 switch {
160 case b.whatsapp == nil:
161 err = fmt.Errorf("unknown tool %q", name)
162 case err == nil:
163 output, err = b.whatsapp.Find(ctx, args.Name)
164 }
165 default:
166 err = fmt.Errorf("unknown tool %q", name)
167 }
168
169 if err != nil {
170 return "error: " + err.Error()
171 }
172
173 return output
174 }
175
176 // zoomAgent returns the whole log of the subagent name.
177 func (b *Toolbox) zoomAgent(ctx context.Context, name string) (string, error) {
178 subagent, err := b.subagents.Get(ctx, name)
179 if err != nil {
180 return "", err
181 }
182
183 return subagent.Show()
184 }
185
186 // zoom returns message id whole for n = 1, or else the two lines under the line
187 // id+n.
188 func (b *Toolbox) zoom(id, n int) (string, error) {
189 if n < 1 || bits.OnesCount(uint(n)) != 1 || id < 0 || id%n != 0 {
190 return "", fmt.Errorf("line %d+%d does not exist: n must be a power of 2 and id a multiple of n", id, n)
191 }
192
193 if id+n > b.chat.Len() {
194 return "", fmt.Errorf("line %d+%d goes past the last message, %d", id, n, b.chat.Len()-1)
195 }
196
197 if n == 1 {
198 message, err := b.chat.Message(id)
199 if err != nil {
200 return "", err
201 }
202
203 return leafSource(message), nil
204 }
205
206 level := bits.TrailingZeros(uint(n)) - 1
207 var lines []string
208 for _, index := range []int{2 * id / n, 2*id/n + 1} {
209 node, built, err := b.chat.Node(level, index)
210 if err != nil {
211 return "", err
212 }
213
214 text := viewPending
215 if built {
216 text = strings.ReplaceAll(node.Text, "\n", " ")
217 }
218
219 lines = append(lines, fmt.Sprintf("%d+%d|%s", index<<level, 1<<level, text))
220 }
221
222 return strings.Join(lines, "\n"), nil
223 }
224
225 // date returns the date and time of message id, with its weekday.
226 func (b *Toolbox) date(id int) (string, error) {
227 if id < 0 || id >= b.chat.Len() {
228 return "", fmt.Errorf("message %d does not exist", id)
229 }
230
231 message, err := b.chat.Message(id)
232 if err != nil {
233 return "", err
234 }
235
236 date, err := time.Parse(time.RFC3339Nano, message.Date)
237 if err != nil {
238 return "", err
239 }
240
241 return date.Local().Format("Monday 2006-01-02 15:04:05 -07:00"), nil
242 }
243
244 // shell runs command with bash in the working directory and returns its output
245 // and error output. A failed command adds its exit status. When ctx ends, the
246 // whole process group of the command is killed.
247 func (b *Toolbox) shell(ctx context.Context, command string) string {
248 cmd := exec.CommandContext(ctx, "bash", "-c", command)
249 cmd.Dir = b.workdir
250 cmd.SysProcAttr = &syscall.SysProcAttr{
251 Setpgid: true,
252 }
253 cmd.Cancel = func() error {
254 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
255 }
256 cmd.WaitDelay = toolShellWaitDelay
257
258 output := &ToolHeadTail{}
259 cmd.Stdout = output
260 cmd.Stderr = output
261 err := cmd.Run()
262
263 text := output.String()
264 var exit *exec.ExitError
265 switch {
266 case ctx.Err() != nil:
267 text += "\n[interrupted]"
268 case errors.As(err, &exit):
269 text += "\n[" + exit.String() + "]"
270 case err != nil:
271 text += "\n[error: " + err.Error() + "]"
272 case text == "":
273 text = "(no output)"
274 }
275
276 return text
277 }
278
279 // read returns the text of the file at path.
280 func (b *Toolbox) read(path string) (text string, err error) {
281 file, err := os.Open(b.path(path))
282 if err != nil {
283 return "", err
284 }
285
286 defer func() {
287 err = errors.Join(err, file.Close())
288 }()
289
290 output := &ToolHeadTail{}
291 _, err = io.Copy(output, file)
292 if err != nil {
293 return "", err
294 }
295
296 return output.String(), nil
297 }
298
299 // write replaces the file at path with text, and makes its missing
300 // directories.
301 func (b *Toolbox) write(path, text string) (string, error) {
302 path = b.path(path)
303 err := os.MkdirAll(filepath.Dir(path), 0o755)
304 if err != nil {
305 return "", err
306 }
307
308 err = os.WriteFile(path, []byte(text), 0o644)
309 if err != nil {
310 return "", err
311 }
312
313 return fmt.Sprintf("wrote %d bytes to %s", len(text), path), nil
314 }
315
316 // edit replaces the one occurrence of old in the file at path with
317 // replacement. It keeps the mode of the file.
318 func (b *Toolbox) edit(path, old, replacement string) (string, error) {
319 path = b.path(path)
320 data, err := os.ReadFile(path)
321 if err != nil {
322 return "", err
323 }
324
325 if old == "" {
326 return "", errors.New("old is empty")
327 }
328
329 count := strings.Count(string(data), old)
330 if count != 1 {
331 return "", fmt.Errorf("old occurs %d times in %s, not once", count, path)
332 }
333
334 info, err := os.Stat(path)
335 if err != nil {
336 return "", err
337 }
338
339 err = os.WriteFile(path, []byte(strings.Replace(string(data), old, replacement, 1)), info.Mode().Perm())
340 if err != nil {
341 return "", err
342 }
343
344 return "edited " + path, nil
345 }
346
347 // path returns path, relative to the working directory when it is not
348 // absolute.
349 func (b *Toolbox) path(path string) string {
350 if filepath.IsAbs(path) {
351 return path
352 }
353
354 return filepath.Join(b.workdir, path)
355 }
356
357 // ToolZoomArguments are the arguments of zoom. A Name that is not nil names
358 // the subagent whose whole log zoom gives, and then ID and N are not used.
359 type ToolZoomArguments struct {
360 ID int `json:"id"`
361 N int `json:"n"`
362 Name *string `json:"name"`
363 }
364
365 // ToolDateArguments are the arguments of date.
366 type ToolDateArguments struct {
367 ID int `json:"id"`
368 }
369
370 // ToolShellArguments are the arguments of shell.
371 type ToolShellArguments struct {
372 Command string `json:"command"`
373 }
374
375 // ToolPathArguments are the arguments of read.
376 type ToolPathArguments struct {
377 Path string `json:"path"`
378 }
379
380 // ToolWriteArguments are the arguments of write.
381 type ToolWriteArguments struct {
382 Path string `json:"path"`
383 Text string `json:"text"`
384 }
385
386 // ToolEditArguments are the arguments of edit.
387 type ToolEditArguments struct {
388 Path string `json:"path"`
389 Old string `json:"old"`
390 New string `json:"new"`
391 }
392
393 // ToolSpawnArguments are the arguments of spawn. A nil Effort is the effort of
394 // the agent.
395 type ToolSpawnArguments struct {
396 Tasks []string `json:"tasks"`
397 Effort *string `json:"effort"`
398 }
399
400 // ToolTellArguments are the arguments of tell.
401 type ToolTellArguments struct {
402 Name string `json:"name"`
403 Message string `json:"message"`
404 }
405
406 // ToolWhatsAppFindArguments are the arguments of whatsapp_find.
407 type ToolWhatsAppFindArguments struct {
408 Name string `json:"name"`
409 }
410
411 // ToolHeadTail keeps the first and the last toolClipBytes bytes written to it,
412 // and counts the bytes between them that it drops.
413 type ToolHeadTail struct {
414 head []byte
415 tail []byte
416 dropped int
417 }
418
419 // String returns the output, clipped when it is too long. Invalid UTF-8 becomes
420 // the replacement character.
421 func (h *ToolHeadTail) String() string {
422 replacement := string(utf8.RuneError)
423 if h.dropped == 0 {
424 return toolClip(strings.ToValidUTF8(string(h.head)+string(h.tail), replacement))
425 }
426
427 head := toolFirstCharacters(strings.ToValidUTF8(string(h.head), replacement), toolClipCharacters/2)
428 tail := toolLastCharacters(strings.ToValidUTF8(string(h.tail), replacement), toolClipCharacters/2)
429 cut := len(h.head) + h.dropped + len(h.tail) - len(head) - len(tail)
430
431 return toolJoinClipped(head, tail, cut)
432 }
433
434 // Write keeps the bytes of p at the ends of the output.
435 func (h *ToolHeadTail) Write(p []byte) (int, error) {
436 n := len(p)
437 room := min(toolClipBytes-len(h.head), len(p))
438 h.head = append(h.head, p[:room]...)
439 p = p[room:]
440
441 h.tail = append(h.tail, p...)
442 if len(h.tail) > toolClipBytes {
443 cut := len(h.tail) - toolClipBytes
444 h.dropped += cut
445 h.tail = bytes.Clone(h.tail[cut:])
446 }
447
448 return n, nil
449 }
450
451 // toolCallText returns the log text of a tool call: the name and the JSON
452 // arguments.
453 func toolCallText(name, arguments string) string {
454 return name + " " + arguments
455 }
456
457 // toolClip returns text when it has at most toolClipCharacters characters.
458 // Otherwise it returns the first and the last toolClipCharacters/2 characters,
459 // with a mark of the cut between them.
460 func toolClip(text string) string {
461 if utf8.RuneCountInString(text) <= toolClipCharacters {
462 return text
463 }
464
465 head := toolFirstCharacters(text, toolClipCharacters/2)
466 tail := toolLastCharacters(text, toolClipCharacters/2)
467
468 return toolJoinClipped(head, tail, len(text)-len(head)-len(tail))
469 }
470
471 // toolDecodeArguments decodes the JSON arguments of a call into args. An
472 // unknown field is an error.
473 func toolDecodeArguments(arguments string, args any) error {
474 decoder := json.NewDecoder(strings.NewReader(arguments))
475 decoder.DisallowUnknownFields()
476 err := decoder.Decode(args)
477 if err != nil {
478 return fmt.Errorf("arguments: %w", err)
479 }
480
481 return nil
482 }
483
484 // toolFirstCharacters returns the first count characters of text.
485 func toolFirstCharacters(text string, count int) string {
486 end := 0
487 for range count {
488 if end >= len(text) {
489 break
490 }
491
492 _, size := utf8.DecodeRuneInString(text[end:])
493 end += size
494 }
495
496 return text[:end]
497 }
498
499 // toolLastCharacters returns the last count characters of text.
500 func toolLastCharacters(text string, count int) string {
501 start := len(text)
502 for range count {
503 if start <= 0 {
504 break
505 }
506
507 _, size := utf8.DecodeLastRuneInString(text[:start])
508 start -= size
509 }
510
511 return text[start:]
512 }
513
514 // toolJoinClipped joins the head and the tail of a clipped output with a mark
515 // of the cut bytes.
516 func toolJoinClipped(head, tail string, cut int) string {
517 return head + "\n[... " + strconv.Itoa(cut) + " bytes cut ...]\n" + tail
518 }