return m.chat.Render()
}
+// Len returns the number of messages in the log.
+func (m *Memory) Len() int {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+
+ return m.log.Len()
+}
+
+// Message reads the message with the given ID.
+func (m *Memory) Message(id int) (Message, error) {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+
+ return m.log.Message(id)
+}
+
+// Node reads the node (l, i) if it is built.
+func (m *Memory) Node(level, index int) (Node, bool, error) {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+
+ return m.tree.Node(level, index)
+}
+
// Context returns the compaction view for the build of node (l, i). It ends
// before the message of a leaf, or after the last message of a merge, and it
// stops at the first line that is not built.
return text + instructionsHeading + instructions
}
-// agentTools returns the function tools of every call: zoom and date. A
-// compaction sends them too and never calls them, because a change of the
-// tools ends the shared prompt cache.
+// agentTools returns the function tools of every call: zoom, date, shell,
+// read, write, and edit. A compaction sends them too and never calls them,
+// because a change of the tools ends the shared prompt cache.
func agentTools() []Tool {
return []Tool{
{
}),
Strict: true,
},
+ {
+ Type: "function",
+ Name: "shell",
+ Description: "Run a command with bash, as the user, in the working directory, with no input. Gives its output and error output, and its exit status when it fails.",
+ Parameters: objectSchema(map[string]any{
+ "command": stringSchema("The bash command."),
+ }),
+ Strict: true,
+ },
+ {
+ Type: "function",
+ Name: "read",
+ Description: "Read a text file.",
+ Parameters: objectSchema(map[string]any{
+ "path": stringSchema("The file, absolute or relative to the working directory."),
+ }),
+ Strict: true,
+ },
+ {
+ Type: "function",
+ Name: "write",
+ Description: "Write a text file whole, replacing it when it exists. Makes the missing directories.",
+ Parameters: objectSchema(map[string]any{
+ "path": stringSchema("The file, absolute or relative to the working directory."),
+ "text": stringSchema("The whole new content."),
+ }),
+ Strict: true,
+ },
+ {
+ Type: "function",
+ Name: "edit",
+ Description: "Replace the one occurrence of old in a text file with new.",
+ Parameters: objectSchema(map[string]any{
+ "path": stringSchema("The file, absolute or relative to the working directory."),
+ "old": stringSchema("The exact text to replace. It must occur once in the file."),
+ "new": stringSchema("The text that replaces old."),
+ }),
+ Strict: true,
+ },
}
}
"description": description,
}
}
+
+// stringSchema returns the JSON schema of a string.
+func stringSchema(description string) map[string]any {
+ return map[string]any{
+ "type": "string",
+ "description": description,
+ }
+}
func TestAgentToolsAreStrictAndStable(t *testing.T) {
tools := agentTools()
- require.Equal(t, []string{"zoom", "date"}, []string{tools[0].Name, tools[1].Name})
+ names := make([]string, len(tools))
+ for n, tool := range tools {
+ names[n] = tool.Name
+ }
+
+ require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, names)
for _, tool := range tools {
schema := tool.Parameters.(map[string]any)
--- /dev/null
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math/bits"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+ "unicode/utf8"
+)
+
+const (
+ // clipCharacters is the most characters of a tool output: its head and
+ // its tail, half each.
+ clipCharacters = 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
+
+ // shellWaitDelay is how long a command can keep its output open after it
+ // ends or after its kill.
+ shellWaitDelay = 2 * time.Second
+)
+
+// chatLog is the log that the tools write their calls and results to.
+type chatLog 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 {
+ 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
+// 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"`
+}
+
+// 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))
+ if err != nil {
+ return "", err
+ }
+
+ err = b.log.Flush(ctx)
+ if err != nil && ctx.Err() == nil {
+ return "", err
+ }
+
+ output := "not run: the turn was interrupted"
+ if err == nil {
+ output = clip(b.run(ctx, name, arguments))
+ }
+
+ _, err = b.log.Append("echo", output)
+ if err != nil {
+ return "", 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
+ switch name {
+ case "zoom":
+ var args zoomArguments
+ err = decodeArguments(arguments, &args)
+ if err == nil {
+ output, err = b.zoom(args.ID, args.N)
+ }
+ case "date":
+ var args dateArguments
+ err = decodeArguments(arguments, &args)
+ if err == nil {
+ output, err = b.date(args.ID)
+ }
+ case "shell":
+ var args shellArguments
+ err = decodeArguments(arguments, &args)
+ if err == nil {
+ output = b.shell(ctx, args.Command)
+ }
+ case "read":
+ var args pathArguments
+ err = decodeArguments(arguments, &args)
+ if err == nil {
+ output, err = b.read(args.Path)
+ }
+ case "write":
+ var args writeArguments
+ err = decodeArguments(arguments, &args)
+ if err == nil {
+ output, err = b.write(args.Path, args.Text)
+ }
+ case "edit":
+ var args editArguments
+ err = decodeArguments(arguments, &args)
+ if err == nil {
+ output, err = b.edit(args.Path, args.Old, args.New)
+ }
+ default:
+ err = fmt.Errorf("unknown tool %q", name)
+ }
+
+ if err != nil {
+ return "error: " + err.Error()
+ }
+
+ return output
+}
+
+// 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) {
+ 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)
+ }
+
+ if id+n > b.chat.Len() {
+ return "", fmt.Errorf("line %d+%d goes past the last message, %d", id, n, b.chat.Len()-1)
+ }
+
+ if n == 1 {
+ message, err := b.chat.Message(id)
+ if err != nil {
+ return "", 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
+ }
+
+ text := pending
+ if built {
+ text = strings.ReplaceAll(node.Text, "\n", " ")
+ }
+
+ lines = append(lines, fmt.Sprintf("%d+%d|%s", index<<level, 1<<level, text))
+ }
+
+ return strings.Join(lines, "\n"), nil
+}
+
+// date returns the date and time of message id, with its weekday.
+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
+ }
+
+ date, err := time.Parse(time.RFC3339Nano, message.Date)
+ if err != nil {
+ return "", err
+ }
+
+ return date.Local().Format("Monday 2006-01-02 15:04:05 -07:00"), nil
+}
+
+// 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 {
+ cmd := exec.CommandContext(ctx, "bash", "-c", command)
+ cmd.Dir = b.workdir
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Setpgid: true,
+ }
+ cmd.Cancel = func() error {
+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ cmd.WaitDelay = shellWaitDelay
+
+ output := &headTail{}
+ cmd.Stdout = output
+ cmd.Stderr = output
+ err := cmd.Run()
+
+ text := output.String()
+ var exit *exec.ExitError
+ switch {
+ case ctx.Err() != nil:
+ text += "\n[interrupted]"
+ case errors.As(err, &exit):
+ text += "\n[" + exit.String() + "]"
+ case err != nil:
+ text += "\n[error: " + err.Error() + "]"
+ case text == "":
+ text = "(no output)"
+ }
+
+ return text
+}
+
+// read returns the text of the file at path.
+func (b *toolbox) read(path string) (text string, err error) {
+ file, err := os.Open(b.path(path))
+ if err != nil {
+ return "", err
+ }
+
+ defer func() {
+ err = errors.Join(err, file.Close())
+ }()
+
+ output := &headTail{}
+ _, err = io.Copy(output, file)
+ if err != nil {
+ return "", err
+ }
+
+ return output.String(), nil
+}
+
+// write replaces the file at path with text, and makes its missing
+// directories.
+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
+ }
+
+ err = os.WriteFile(path, []byte(text), 0o644)
+ if err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("wrote %d bytes to %s", len(text), path), 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) {
+ path = b.path(path)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+
+ if old == "" {
+ return "", errors.New("old is empty")
+ }
+
+ count := strings.Count(string(data), old)
+ if count != 1 {
+ return "", fmt.Errorf("old occurs %d times in %s, not once", count, path)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ return "", err
+ }
+
+ err = os.WriteFile(path, []byte(strings.Replace(string(data), old, replacement, 1)), info.Mode().Perm())
+ if err != nil {
+ return "", err
+ }
+
+ return "edited " + path, nil
+}
+
+// path returns path, relative to the working directory when it is not
+// absolute.
+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 {
+ head []byte
+ tail []byte
+ dropped int
+}
+
+// Write keeps the bytes of p at the ends of the output.
+func (h *headTail) Write(p []byte) (int, error) {
+ n := len(p)
+ room := min(clipBytes-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
+ 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))
+ }
+
+ 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)
+
+ return joinClipped(head, tail, cut)
+}
+
+// callText returns the log text of a tool call: the name and the JSON
+// arguments.
+func callText(name, arguments string) string {
+ return name + " " + arguments
+}
+
+// decodeArguments decodes the JSON arguments of a call into args. An unknown
+// field is an error.
+func decodeArguments(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)
+ }
+
+ 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 {
+ end := 0
+ for range count {
+ if end >= len(text) {
+ break
+ }
+
+ _, size := utf8.DecodeRuneInString(text[end:])
+ end += size
+ }
+
+ return text[:end]
+}
+
+// lastCharacters returns the last count characters of text.
+func lastCharacters(text string, count int) string {
+ start := len(text)
+ for range count {
+ if start <= 0 {
+ break
+ }
+
+ _, size := utf8.DecodeLastRuneInString(text[:start])
+ start -= size
+ }
+
+ return text[start:]
+}
--- /dev/null
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+ "unicode/utf8"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestToolRunsAfterItsCallIsDurable(t *testing.T) {
+ dir := t.TempDir()
+ log := &recordingLog{
+ marker: filepath.Join(dir, "ran"),
+ }
+
+ box := newTestToolbox(t, log, dir)
+ output, err := box.Call(t.Context(), "shell", `{"command":"touch ran && echo done"}`)
+ require.NoError(t, err)
+ require.Equal(t, "done\n", output)
+ require.Equal(t, []string{
+ `tool: shell {"command":"touch ran && echo done"}`,
+ "flush, marker absent",
+ "echo: done\n",
+ }, log.Events())
+ require.FileExists(t, filepath.Join(dir, "ran"))
+}
+
+func TestToolDoesNotRunAfterAnInterruptBeforeTheFlush(t *testing.T) {
+ dir := t.TempDir()
+ log := &recordingLog{
+ marker: filepath.Join(dir, "ran"),
+ }
+
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+
+ box := newTestToolbox(t, log, dir)
+ output, err := box.Call(ctx, "shell", `{"command":"touch ran"}`)
+ require.NoError(t, err)
+ require.Equal(t, "not run: the turn was interrupted", output)
+ require.NoFileExists(t, filepath.Join(dir, "ran"))
+ require.Equal(t, "echo: not run: the turn was interrupted", log.Events()[2])
+}
+
+func TestShellRunsInTheWorkingDirectory(t *testing.T) {
+ dir := t.TempDir()
+ box := newTestToolbox(t, &recordingLog{}, dir)
+ testCases := []struct {
+ name string
+ command string
+ output string
+ }{
+ {
+ name: "working directory",
+ command: "pwd",
+ output: dir + "\n",
+ },
+ {
+ name: "error output and exit status",
+ command: "echo out; echo err >&2; exit 3",
+ output: "out\nerr\n\n[exit status 3]",
+ },
+ {
+ name: "no output",
+ command: "true",
+ output: "(no output)",
+ },
+ {
+ name: "no input",
+ command: "cat",
+ output: "(no output)",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ require.Equal(t, tc.output, box.run(t.Context(), "shell", fmt.Sprintf(`{"command":%q}`, tc.command)))
+ })
+ }
+}
+
+func TestShellInterruptKillsTheCommandAndItsChildren(t *testing.T) {
+ box := newTestToolbox(t, &recordingLog{}, t.TempDir())
+ ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
+ defer cancel()
+
+ start := time.Now()
+ output := box.shell(ctx, "echo started; sleep 30 & sleep 30")
+ require.Less(t, time.Since(start), 10*time.Second)
+ require.Equal(t, "started\n\n[interrupted]", output)
+}
+
+func TestFileTools(t *testing.T) {
+ dir := t.TempDir()
+ box := newTestToolbox(t, &recordingLog{}, 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"}`))
+ require.Equal(t, "one two two\n", box.run(t.Context(), "read", fmt.Sprintf(`{"path":%q}`, path)))
+ require.Equal(t, "error: old occurs 2 times in "+path+", not once", box.run(t.Context(), "edit", `{"path":"notes/a.txt","old":"two","new":"2"}`))
+ require.Equal(t, "edited "+path, box.run(t.Context(), "edit", `{"path":"notes/a.txt","old":"one","new":"1"}`))
+ require.Equal(t, "1 two two\n", box.run(t.Context(), "read", `{"path":"notes/a.txt"}`))
+ require.Contains(t, box.run(t.Context(), "read", `{"path":"missing.txt"}`), "error: open "+filepath.Join(dir, "missing.txt"))
+ require.Equal(t, `error: arguments: json: unknown field "file"`, box.run(t.Context(), "read", `{"file":"a.txt"}`))
+ require.Equal(t, `error: unknown tool "grep"`, box.run(t.Context(), "grep", `{}`))
+}
+
+func TestZoomAndDateReadTheChat(t *testing.T) {
+ memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer())
+ require.NoError(t, err)
+
+ defer func() {
+ require.NoError(t, memory.Close())
+ }()
+
+ for _, text := range []string{"zero", "one", "two", "three"} {
+ _, err = memory.Append("user", text)
+ require.NoError(t, err)
+ }
+
+ box := &toolbox{
+ log: memory,
+ chat: memory,
+ workdir: t.TempDir(),
+ }
+
+ testCases := []struct {
+ name string
+ arguments string
+ output string
+ }{
+ {
+ name: "one message whole",
+ arguments: `{"id":2,"n":1}`,
+ output: "user: two",
+ },
+ {
+ name: "a line opens into its two halves",
+ arguments: `{"id":0,"n":4}`,
+ output: "0+2|user: zero user: one\n2+2|user: two user: three",
+ },
+ {
+ name: "an id that is not a multiple of n",
+ arguments: `{"id":1,"n":2}`,
+ output: "error: line 1+2 does not exist: n must be a power of 2 and id a multiple of n",
+ },
+ {
+ name: "a line past the last message",
+ arguments: `{"id":4,"n":4}`,
+ output: "error: line 4+4 goes past the last message, 3",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ require.Equal(t, tc.output, box.run(t.Context(), "zoom", tc.arguments))
+ })
+ }
+
+ date := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC).Local().Format("Monday 2006-01-02 15:04:05 -07:00")
+ require.Equal(t, date, box.run(t.Context(), "date", `{"id":3}`))
+ require.Equal(t, "error: message 4 does not exist", box.run(t.Context(), "date", `{"id":4}`))
+}
+
+func TestClipKeepsTheHeadAndTheTail(t *testing.T) {
+ short := strings.Repeat("a", clipCharacters)
+ require.Equal(t, short, clip(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))
+
+ half := strings.Repeat("é", clipCharacters/2)
+ clipped := clip(strings.Repeat("é", clipCharacters+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{}
+ for n := range 1000 {
+ _, err := fmt.Fprintf(output, "%04d%s\n", n, strings.Repeat("x", 995))
+ require.NoError(t, err)
+ }
+
+ 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))
+}
+
+// recordingLog is a chat log that records each append and flush. The flush
+// records whether the marker file exists.
+type recordingLog struct {
+ mutex sync.Mutex
+ marker string
+ events []string
+}
+
+func (r *recordingLog) Append(kind, text string) (Message, error) {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ r.events = append(r.events, kind+": "+text)
+
+ return Message{}, nil
+}
+
+func (r *recordingLog) Flush(ctx context.Context) error {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ state := "absent"
+ _, err := os.Stat(r.marker)
+ if err == nil {
+ state = "present"
+ }
+
+ r.events = append(r.events, "flush, marker "+state)
+
+ return ctx.Err()
+}
+
+func (r *recordingLog) Events() []string {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ return append([]string(nil), r.events...)
+}
+
+// newTestToolbox returns a toolbox with log, an empty chat, and the working
+// directory dir.
+func newTestToolbox(t *testing.T, log chatLog, dir string) *toolbox {
+ t.Helper()
+
+ memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newFakeSummarizer())
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ require.NoError(t, memory.Close())
+ })
+
+ return &toolbox{
+ log: log,
+ chat: memory,
+ workdir: dir,
+ }
+}