)
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 toolbox of a subagent has none, so spawn and tell are unknown tools
+// there.
+type Toolbox struct {
+ log ToolLog
+ chat ToolChat
+ subagents *Subagents
+ runs *SubagentRuns
+ 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
}
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)
}
// run runs one tool and returns its output, or its error as text.
-func (b *toolbox) run(ctx context.Context, name, arguments string) string {
+func (b *Toolbox) run(ctx context.Context, name, arguments string) string {
var output string
var 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)
+ }
default:
err = fmt.Errorf("unknown tool %q", name)
}
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)
}
return "", err
}
- text := pending
+ text := viewPending
if built {
text = strings.ReplaceAll(node.Text, "\n", " ")
}
}
// 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)
}
// 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{
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()
}
// 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
err = errors.Join(err, file.Close())
}()
- output := &headTail{}
+ output := &ToolHeadTail{}
_, err = io.Copy(output, file)
if err != nil {
return "", err
// 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 {
// 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 {
// 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
}
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"`
+}
+
+// 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:])
}
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)
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) {
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 {
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
+}