From: Anderson A. Giacomolli Date: Sat, 7 Nov 2026 16:59:39 +0000 (-0300) Subject: Name and order the tool code X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/1e7f78e7b12d3881a6c345d191dfef6e42d9b01f Name and order the tool code Every package-level name of the tools starts with tool: the Toolbox, its interfaces ToolLog and ToolChat, the argument types, the head and tail buffer, and the clip helpers. The interfaces chatLog and chatReader read as parts of the chat, but only the tools use them. The declarations of tools.go follow the order of the go-code skill. The behavior does not change. Issue: 947bddb --- diff --git a/tools.go b/tools.go index 7ca031c..af500ba 100644 --- a/tools.go +++ b/tools.go @@ -19,83 +19,49 @@ 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 +// 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 ToolLog + chat ToolChat 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"` -} - // 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 } @@ -107,7 +73,7 @@ func (b *toolbox) Call(ctx context.Context, name, arguments string) (string, 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) @@ -119,43 +85,43 @@ func (b *toolbox) Call(ctx context.Context, name, arguments string) (string, err } // 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) + var args ToolZoomArguments + err = toolDecodeArguments(arguments, &args) if err == nil { 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) } @@ -172,7 +138,7 @@ func (b *toolbox) run(ctx context.Context, name, arguments string) string { // 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) } @@ -210,7 +176,7 @@ 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) } @@ -231,7 +197,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,9 +206,9 @@ 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() @@ -264,7 +230,7 @@ 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 @@ -274,7 +240,7 @@ func (b *toolbox) read(path string) (text string, err error) { err = errors.Join(err, file.Close()) }() - output := &headTail{} + output := &ToolHeadTail{} _, err = io.Copy(output, file) if err != nil { return "", err @@ -285,7 +251,7 @@ 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 { @@ -302,7 +268,7 @@ 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 { @@ -333,7 +299,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 +307,73 @@ 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. +type ToolZoomArguments struct { + ID int `json:"id"` + N int `json:"n"` +} + +// 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"` +} + +// 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,30 +381,29 @@ 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) @@ -400,28 +414,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 +429,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 +443,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 +} diff --git a/tools_test.go b/tools_test.go index a5f8f18..8960b8f 100644 --- a/tools_test.go +++ b/tools_test.go @@ -16,7 +16,7 @@ import ( func TestToolRunsAfterItsCallIsDurable(t *testing.T) { dir := t.TempDir() - log := &recordingLog{ + log := &ToolRecordingLog{ marker: filepath.Join(dir, "ran"), } @@ -34,7 +34,7 @@ func TestToolRunsAfterItsCallIsDurable(t *testing.T) { func TestToolDoesNotRunAfterAnInterruptBeforeTheFlush(t *testing.T) { dir := t.TempDir() - log := &recordingLog{ + log := &ToolRecordingLog{ marker: filepath.Join(dir, "ran"), } @@ -51,7 +51,7 @@ func TestToolDoesNotRunAfterAnInterruptBeforeTheFlush(t *testing.T) { func TestShellRunsInTheWorkingDirectory(t *testing.T) { dir := t.TempDir() - box := newTestToolbox(t, &recordingLog{}, dir) + box := newTestToolbox(t, &ToolRecordingLog{}, dir) testCases := []struct { name string command string @@ -87,7 +87,7 @@ func TestShellRunsInTheWorkingDirectory(t *testing.T) { } func TestShellInterruptKillsTheCommandAndItsChildren(t *testing.T) { - box := newTestToolbox(t, &recordingLog{}, t.TempDir()) + box := newTestToolbox(t, &ToolRecordingLog{}, t.TempDir()) ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) defer cancel() @@ -99,7 +99,7 @@ func TestShellInterruptKillsTheCommandAndItsChildren(t *testing.T) { func TestFileTools(t *testing.T) { dir := t.TempDir() - box := newTestToolbox(t, &recordingLog{}, dir) + box := newTestToolbox(t, &ToolRecordingLog{}, dir) path := filepath.Join(dir, "notes", "a.txt") require.Equal(t, "wrote 12 bytes to "+path, box.run(t.Context(), "write", `{"path":"notes/a.txt","text":"one two two\n"}`)) @@ -125,7 +125,7 @@ func TestZoomAndDateReadTheChat(t *testing.T) { require.NoError(t, err) } - box := &toolbox{ + box := &Toolbox{ log: memory, chat: memory, workdir: t.TempDir(), @@ -170,20 +170,20 @@ func TestZoomAndDateReadTheChat(t *testing.T) { } func TestClipKeepsTheHeadAndTheTail(t *testing.T) { - short := strings.Repeat("a", clipCharacters) - require.Equal(t, short, clip(short)) + short := strings.Repeat("a", toolClipCharacters) + require.Equal(t, short, toolClip(short)) - text := strings.Repeat("h", clipCharacters/2) + strings.Repeat("é", 5000) + strings.Repeat("t", clipCharacters/2) - require.Equal(t, strings.Repeat("h", clipCharacters/2)+"\n[... 10000 bytes cut ...]\n"+strings.Repeat("t", clipCharacters/2), clip(text)) + text := strings.Repeat("h", toolClipCharacters/2) + strings.Repeat("é", 5000) + strings.Repeat("t", toolClipCharacters/2) + require.Equal(t, strings.Repeat("h", toolClipCharacters/2)+"\n[... 10000 bytes cut ...]\n"+strings.Repeat("t", toolClipCharacters/2), toolClip(text)) - half := strings.Repeat("é", clipCharacters/2) - clipped := clip(strings.Repeat("é", clipCharacters+2)) + half := strings.Repeat("é", toolClipCharacters/2) + clipped := toolClip(strings.Repeat("é", toolClipCharacters+2)) require.True(t, utf8.ValidString(clipped)) require.Equal(t, half+"\n[... 4 bytes cut ...]\n"+half, clipped) } func TestHeadTailKeepsTheEndsOfALongOutput(t *testing.T) { - output := &headTail{} + output := &ToolHeadTail{} for n := range 1000 { _, err := fmt.Fprintf(output, "%04d%s\n", n, strings.Repeat("x", 995)) require.NoError(t, err) @@ -192,19 +192,19 @@ func TestHeadTailKeepsTheEndsOfALongOutput(t *testing.T) { text := output.String() require.True(t, strings.HasPrefix(text, "0000xxx")) require.True(t, strings.HasSuffix(text, "0999"+strings.Repeat("x", 995)+"\n")) - require.Contains(t, text, fmt.Sprintf("\n[... %d bytes cut ...]\n", 1000*1000-clipCharacters)) - require.Equal(t, clipCharacters+len(fmt.Sprintf("\n[... %d bytes cut ...]\n", 1000*1000-clipCharacters)), len(text)) + require.Contains(t, text, fmt.Sprintf("\n[... %d bytes cut ...]\n", 1000*1000-toolClipCharacters)) + require.Equal(t, toolClipCharacters+len(fmt.Sprintf("\n[... %d bytes cut ...]\n", 1000*1000-toolClipCharacters)), len(text)) } -// recordingLog is a chat log that records each append and flush. The flush +// ToolRecordingLog is a chat log that records each append and flush. The flush // records whether the marker file exists. -type recordingLog struct { +type ToolRecordingLog struct { mutex sync.Mutex marker string events []string } -func (r *recordingLog) Append(kind, text string) (Message, error) { +func (r *ToolRecordingLog) Append(kind, text string) (Message, error) { r.mutex.Lock() defer r.mutex.Unlock() @@ -213,7 +213,7 @@ func (r *recordingLog) Append(kind, text string) (Message, error) { return Message{}, nil } -func (r *recordingLog) Flush(ctx context.Context) error { +func (r *ToolRecordingLog) Flush(ctx context.Context) error { r.mutex.Lock() defer r.mutex.Unlock() @@ -228,7 +228,7 @@ func (r *recordingLog) Flush(ctx context.Context) error { return ctx.Err() } -func (r *recordingLog) Events() []string { +func (r *ToolRecordingLog) Events() []string { r.mutex.Lock() defer r.mutex.Unlock() @@ -237,7 +237,7 @@ func (r *recordingLog) Events() []string { // newTestToolbox returns a toolbox with log, an empty chat, and the working // directory dir. -func newTestToolbox(t *testing.T, log chatLog, dir string) *toolbox { +func newTestToolbox(t *testing.T, log ToolLog, dir string) *Toolbox { t.Helper() memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer()) @@ -247,7 +247,7 @@ func newTestToolbox(t *testing.T, log chatLog, dir string) *toolbox { require.NoError(t, memory.Close()) }) - return &toolbox{ + return &Toolbox{ log: log, chat: memory, workdir: dir, diff --git a/turn.go b/turn.go index c0a87e8..c6a083b 100644 --- a/turn.go +++ b/turn.go @@ -32,7 +32,7 @@ func newTurnLoop(agent *Agent, memory *Memory, client TurnClient, clock func() t wake: make(chan struct{}, 1), } - l.tools = &toolbox{ + l.tools = &Toolbox{ log: l, chat: memory, workdir: agent.config.Workdir, @@ -78,7 +78,7 @@ type TurnLoop struct { agent *Agent memory *Memory client TurnClient - tools *toolbox + tools *Toolbox layout *ViewLayout clock func() time.Time notify func(Event)