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