]> git.giacomolli.uk - onechat/commitdiff
Store the subagents and read them with zoom
authorAnderson A. Giacomolli <redacted>
Sun, 8 Nov 2026 13:23:35 +0000 (10:23 -0300)
committerAnderson A. Giacomolli <redacted>
Sun, 8 Nov 2026 13:23:35 +0000 (10:23 -0300)
Each subagent has a directory in subagents/ of the agent directory,
named for it, with its own message log. A name is an adjective and a
noun from two word lists, each capitalized and joined, such as
GreenOtter, and a new subagent draws again when the directory of its
name exists. Only such a name can be a directory, so a name never leaves
subagents/.

The zoom tool gets a nullable name, and zoom("Name") gives the whole log
of that subagent, each message as "i|kind: text". The prompt gets the
tool line for it. The log of a name opens once and stays open until the
daemon stops, because its lock allows one open log, and its mutex keeps
the run of the subagent and a zoom of the agent apart.

Issue: b1ac698

13 files changed:
chain_test.go
daemon.go
daemon_start_helper_test.go
prompt.go
prompt_test.go
subagent.go [new file with mode: 0644]
subagent_helper_test.go [new file with mode: 0644]
subagent_names.go [new file with mode: 0644]
subagent_test.go [new file with mode: 0644]
tools.go
tools_test.go
turn.go
turn_test.go

index 833f86e7a73e883a12bc814b43d95a42c4be7437..070a5f2a6f4318538b74b538aa3e2328d5b56f63 100644 (file)
@@ -36,9 +36,10 @@ func TestChainReturnsTheRepliesOfTheFinalAnswer(t *testing.T) {
        chain := &Chain{
                client: newModelClient(server.URL, ModelAPIKey(modelTestKey), "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")),
                tools: &Toolbox{
-                       log:     memory,
-                       chat:    memory,
-                       workdir: t.TempDir(),
+                       log:       memory,
+                       chat:      memory,
+                       subagents: newTestSubagents(t),
+                       workdir:   t.TempDir(),
                },
                request: ModelRequest{
                        Model:  "gpt-test",
index 2a23dc28a4405380137213b21f9c25566c575844..3cef419bed9821e984274cf8e8c168b3a12a471b 100644 (file)
--- a/daemon.go
+++ b/daemon.go
@@ -82,6 +82,11 @@ func daemon(ctx context.Context, out io.Writer) (err error) {
                err = errors.Join(err, memory.Close())
        }()
 
+       subagents := newSubagents(filepath.Join(agent.dir, subagentDirName), time.Now, subagentNameRandom)
+       defer func() {
+               err = errors.Join(err, subagents.Close())
+       }()
+
        listener, err := daemonListenSocket(socket)
        if err != nil {
                return err
@@ -102,7 +107,7 @@ func daemon(ctx context.Context, out io.Writer) (err error) {
        }
 
        return daemonServe(ctx, listener, telegram, func(notify func(Event)) (*TurnLoop, error) {
-               return newTurnLoop(agent, memory, client, time.Now, notify)
+               return newTurnLoop(agent, memory, subagents, client, time.Now, notify)
        })
 }
 
index fd89b34bddfa0ae5b176efe7eb0cabb00e9c3b0d..1952e77440ed41408d43f9aa2d23c34a16b32b67 100644 (file)
@@ -30,7 +30,7 @@ func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) s
        done := make(chan error)
        go func() {
                done <- daemonServe(ctx, listener, bot, func(notify func(Event)) (*TurnLoop, error) {
-                       return newTurnLoop(agent, memory, client, clock.Now, notify)
+                       return newTurnLoop(agent, memory, newTestSubagents(t), client, clock.Now, notify)
                })
        }()
 
index c6df0575f5686ef8708b099ddf6d0046180690f0..71671c510caa6b0c3ec837de6555f1ecc5807e76 100644 (file)
--- a/prompt.go
+++ b/prompt.go
@@ -58,6 +58,7 @@ long for one message is split over several in a row.
 Tools:
 - zoom(id, n) opens line id+n into the two lines it was made from;
 - zoom(id, 1) gives message id whole
+- zoom("Name") gives an agent's whole chat
 - date(id) gives the date and time of message id
 
 # Turns
@@ -139,10 +140,11 @@ func promptTools() []ModelTool {
                {
                        Type:        "function",
                        Name:        "zoom",
-                       Description: "Open the line id+n of the view into the two lines of n/2 under it; n = 1 gives the message whole.",
+                       Description: "Open the line id+n of the view into the two lines of n/2 under it; n = 1 gives the message whole. With an agent's name, give that agent's whole chat.",
                        Parameters: promptObjectSchema(map[string]any{
-                               "id": promptIntegerSchema("The first message of the line: a multiple of n."),
-                               "n":  promptIntegerSchema("The number of messages of the line: a power of 2."),
+                               "id":   promptIntegerSchema("The first message of the line: a multiple of n."),
+                               "n":    promptIntegerSchema("The number of messages of the line: a power of 2."),
+                               "name": promptNullableStringSchema("The name of an agent whose whole chat to give, or null for the line id+n."),
                        }),
                        Strict: true,
                },
@@ -223,6 +225,14 @@ func promptIntegerSchema(description string) map[string]any {
        }
 }
 
+// promptNullableStringSchema returns the JSON schema of a string or null.
+func promptNullableStringSchema(description string) map[string]any {
+       return map[string]any{
+               "type":        []string{"string", "null"},
+               "description": description,
+       }
+}
+
 // promptStringSchema returns the JSON schema of a string.
 func promptStringSchema(description string) map[string]any {
        return map[string]any{
index 902cb8677e7d3617f901e4b716c8237a229b701a..5b10597bb755c1f629eefd823ca588a12573f527 100644 (file)
@@ -16,6 +16,7 @@ func TestPromptNamesTheAgent(t *testing.T) {
        require.Contains(t, text, "- reply: Ada's replies")
        require.Contains(t, text, "Goal: let Ada work later")
        require.Contains(t, text, `- work: an agent's report, starting "[Name]"`)
+       require.Contains(t, text, "- zoom(id, 1) gives message id whole\n- zoom(\"Name\") gives an agent's whole chat\n")
        require.NotContains(t, text, promptName)
 
        // The old agent name is built from parts, so the source does not spell
@@ -25,7 +26,7 @@ func TestPromptNamesTheAgent(t *testing.T) {
 
 func TestPromptLeavesOutAbsentFeatures(t *testing.T) {
        text := prompt("Ada", "")
-       for _, absent := range []string{"image", "ubagent", "computer", "device", `zoom("Name")`, "instructions at the end"} {
+       for _, absent := range []string{"image", "ubagent", "computer", "device", "instructions at the end"} {
                require.NotContains(t, text, absent)
        }
 }
diff --git a/subagent.go b/subagent.go
new file mode 100644 (file)
index 0000000..0248751
--- /dev/null
@@ -0,0 +1,194 @@
+package main
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "io/fs"
+       "os"
+       "path/filepath"
+       "strconv"
+       "strings"
+       "sync"
+       "time"
+)
+
+const (
+       // subagentDirName is the directory of the subagents in the directory of
+       // an agent.
+       subagentDirName = "subagents"
+
+       // subagentNameDraws is the most names that Create draws before it
+       // fails.
+       subagentNameDraws = 1000
+)
+
+// newSubagents returns the subagents in dir, one directory for each name. The
+// clock gives the dates of their messages, and draw gives the names of new
+// subagents.
+func newSubagents(dir string, clock func() time.Time, draw func() string) *Subagents {
+       return &Subagents{
+               dir:   dir,
+               clock: clock,
+               draw:  draw,
+               open:  make(map[string]*Subagent),
+       }
+}
+
+// Subagents holds the subagents of one agent. Each subagent has a directory
+// named for it, which holds its log. A log takes an exclusive lock, so
+// Subagents opens the log of a name once and keeps it open until Close. Its
+// mutex protects the open logs.
+type Subagents struct {
+       dir   string
+       clock func() time.Time
+       draw  func() string
+
+       mutex sync.Mutex
+       open  map[string]*Subagent
+}
+
+// Create makes the directory and the log of a new subagent. It draws a name
+// again when the directory of the name exists.
+func (s *Subagents) Create(ctx context.Context) (*Subagent, error) {
+       err := os.MkdirAll(s.dir, 0o700)
+       if err != nil {
+               return nil, fmt.Errorf("create subagent directory: %w", err)
+       }
+
+       for range subagentNameDraws {
+               name := s.draw()
+               err = os.Mkdir(filepath.Join(s.dir, name), 0o700)
+               if errors.Is(err, fs.ErrExist) {
+                       continue
+               }
+
+               if err != nil {
+                       return nil, fmt.Errorf("create subagent directory: %w", err)
+               }
+
+               return s.Get(ctx, name)
+       }
+
+       return nil, fmt.Errorf("no free subagent name after %d draws", subagentNameDraws)
+}
+
+// Get returns the subagent name with its log open. A name that is not valid or
+// that has no directory is an unknown agent.
+func (s *Subagents) Get(ctx context.Context, name string) (*Subagent, error) {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       subagent, found := s.open[name]
+       if found {
+               return subagent, nil
+       }
+
+       unknown := fmt.Errorf("no agent %s", name)
+       if !subagentNameValid(name) {
+               return nil, unknown
+       }
+
+       root := filepath.Join(s.dir, name)
+       info, err := os.Stat(root)
+       if errors.Is(err, fs.ErrNotExist) {
+               return nil, unknown
+       }
+
+       if err != nil {
+               return nil, fmt.Errorf("read subagent directory: %w", err)
+       }
+
+       if !info.IsDir() {
+               return nil, unknown
+       }
+
+       log, err := openLog(ctx, root, s.clock)
+       if err != nil {
+               return nil, fmt.Errorf("open log of %s: %w", name, err)
+       }
+
+       syncer, err := openSyncer(root)
+       if err != nil {
+               return nil, errors.Join(fmt.Errorf("open log of %s: %w", name, err), log.Close())
+       }
+
+       subagent = &Subagent{
+               name:   name,
+               log:    log,
+               syncer: syncer,
+       }
+       s.open[name] = subagent
+
+       return subagent, nil
+}
+
+// Close makes the writes of each open log durable and closes it.
+func (s *Subagents) Close() error {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       var err error
+       for name, subagent := range s.open {
+               err = errors.Join(err, subagent.close())
+               delete(s.open, name)
+       }
+
+       return err
+}
+
+// Subagent is one subagent and its log. Its mutex protects the log, because
+// the run of the subagent writes it while the agent reads it with zoom.
+type Subagent struct {
+       name   string
+       mutex  sync.Mutex
+       log    *Log
+       syncer *Syncer
+}
+
+// Append logs a message of the subagent.
+func (a *Subagent) Append(kind, text string) (Message, error) {
+       a.mutex.Lock()
+       defer a.mutex.Unlock()
+
+       message, err := a.log.Append(kind, text)
+       if err != nil {
+               return Message{}, err
+       }
+
+       a.syncer.Written()
+
+       return message, nil
+}
+
+// Flush returns when every write before the call is durable.
+func (a *Subagent) Flush(ctx context.Context) error {
+       return a.syncer.Flush(ctx)
+}
+
+// Show returns the whole log, each message as "i|kind: text", with a blank line
+// between messages.
+func (a *Subagent) Show() (string, error) {
+       a.mutex.Lock()
+       defer a.mutex.Unlock()
+
+       lines := make([]string, 0, a.log.Len())
+       for id := range a.log.Len() {
+               message, err := a.log.Message(id)
+               if err != nil {
+                       return "", err
+               }
+
+               lines = append(lines, strconv.Itoa(id)+"|"+leafSource(message))
+       }
+
+       return strings.Join(lines, "\n\n"), nil
+}
+
+// close makes the writes durable and closes the log.
+func (a *Subagent) close() error {
+       a.mutex.Lock()
+       defer a.mutex.Unlock()
+
+       return errors.Join(a.syncer.Close(), a.log.Close())
+}
diff --git a/subagent_helper_test.go b/subagent_helper_test.go
new file mode 100644 (file)
index 0000000..195fc01
--- /dev/null
@@ -0,0 +1,21 @@
+package main
+
+import (
+       "path/filepath"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+// newTestSubagents returns the subagents of a new agent directory, with random
+// names, and closes them at the end of the test.
+func newTestSubagents(t *testing.T) *Subagents {
+       t.Helper()
+
+       subagents := newSubagents(filepath.Join(t.TempDir(), subagentDirName), newTestClock().Now, subagentNameRandom)
+       t.Cleanup(func() {
+               require.NoError(t, subagents.Close())
+       })
+
+       return subagents
+}
diff --git a/subagent_names.go b/subagent_names.go
new file mode 100644 (file)
index 0000000..3b9f3a5
--- /dev/null
@@ -0,0 +1,69 @@
+package main
+
+import (
+       "math/rand/v2"
+       "strings"
+)
+
+var (
+       // subagentAdjectives and subagentNouns are the words of the names of
+       // the subagents. A name is an adjective and a noun, each capitalized,
+       // joined, such as GreenOtter: 16,384 names.
+       subagentAdjectives = strings.Fields(`
+red blue green yellow orange purple pink brown black white gray silver golden
+amber azure bronze copper jade lemon lime mint navy peach plum ruby violet
+cherry honey big small tiny huge giant little tall short long round flat thick
+thin deep soft hard smooth rough fuzzy fluffy shiny crunchy crispy leafy spiky
+hot cold warm cool icy frosty snowy rainy sunny cloudy windy stormy foggy
+misty sweet sour salty spicy juicy minty nutty creamy smoky tasty fresh bright
+dark quiet loud happy jolly merry calm brave bold clever wise kind gentle
+lucky proud quick swift fast slow eager busy sleepy silly funny shy neat noble
+grand royal mighty strong steady nimble lively cozy old new young ancient
+wooden stone paper iron magic cosmic lunar solar polar wild
+`)
+       subagentNouns = strings.Fields(`
+ant bat bear bee beetle bird bison camel cat cheetah chicken clam cobra cougar
+cow crab crane crow deer dog dolphin donkey dove dragon duck eagle eel elk
+falcon finch fish flamingo fox frog gazelle gecko giraffe goat goose gorilla
+hamster hare hawk hedgehog heron hippo horse hyena iguana jaguar jay kangaroo
+kitten koala lamb lemur leopard lion lizard llama lobster moose moth mouse
+mule octopus orca otter owl ox oyster panda parrot pelican penguin pig pigeon
+pony puffin puma rabbit raccoon raven rhino robin salmon seal shark sheep
+shrimp skunk sloth snail snake sparrow spider squid squirrel swan tiger toad
+toucan trout tuna turkey turtle walrus wasp whale wolf wombat yak zebra apple
+banana bagel cookie mango melon muffin noodle pancake pepper potato pretzel
+radish taco waffle
+`)
+)
+
+// subagentNameRandom returns a random name of a subagent.
+func subagentNameRandom() string {
+       adjective := subagentAdjectives[rand.IntN(len(subagentAdjectives))]
+       noun := subagentNouns[rand.IntN(len(subagentNouns))]
+
+       return subagentWord(adjective) + subagentWord(noun)
+}
+
+// subagentNameValid tells if name is an adjective and a noun of the word lists,
+// each capitalized, joined. Only such a name can be a directory of a subagent.
+func subagentNameValid(name string) bool {
+       for _, adjective := range subagentAdjectives {
+               noun, found := strings.CutPrefix(name, subagentWord(adjective))
+               if !found {
+                       continue
+               }
+
+               for _, word := range subagentNouns {
+                       if subagentWord(word) == noun {
+                               return true
+                       }
+               }
+       }
+
+       return false
+}
+
+// subagentWord returns word with its first letter in upper case.
+func subagentWord(word string) string {
+       return strings.ToUpper(word[:1]) + word[1:]
+}
diff --git a/subagent_test.go b/subagent_test.go
new file mode 100644 (file)
index 0000000..4fab1b1
--- /dev/null
@@ -0,0 +1,84 @@
+package main
+
+import (
+       "path/filepath"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestSubagentNamesAreAnAdjectiveAndANoun(t *testing.T) {
+       for range 100 {
+               require.True(t, subagentNameValid(subagentNameRandom()))
+       }
+
+       require.True(t, subagentNameValid("GreenOtter"))
+       for _, name := range []string{"", "Green", "Greenotter", "greenOtter", "Green-Otter", "GreenOtterX", "OtterGreen", "../GreenOtter"} {
+               require.False(t, subagentNameValid(name), name)
+       }
+}
+
+func TestSubagentCreateDrawsATakenNameAgain(t *testing.T) {
+       names := []string{"GreenOtter", "GreenOtter", "BlueFox"}
+       subagents := newSubagents(filepath.Join(t.TempDir(), subagentDirName), newTestClock().Now, func() string {
+               name := names[0]
+               names = names[1:]
+
+               return name
+       })
+
+       defer func() {
+               require.NoError(t, subagents.Close())
+       }()
+
+       first, err := subagents.Create(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, "GreenOtter", first.name)
+
+       second, err := subagents.Create(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, "BlueFox", second.name)
+       require.DirExists(t, filepath.Join(subagents.dir, "BlueFox"))
+}
+
+func TestSubagentLogLastsAfterAClose(t *testing.T) {
+       dir := filepath.Join(t.TempDir(), subagentDirName)
+       subagents := newSubagents(dir, newTestClock().Now, func() string {
+               return "GreenOtter"
+       })
+
+       subagent, err := subagents.Create(t.Context())
+       require.NoError(t, err)
+
+       for _, message := range [][2]string{{"note", "subagent"}, {"user", "Count the files."}, {"reply", "There are 3."}} {
+               _, err = subagent.Append(message[0], message[1])
+               require.NoError(t, err)
+       }
+
+       require.NoError(t, subagent.Flush(t.Context()))
+       require.NoError(t, subagents.Close())
+
+       subagents = newSubagents(dir, newTestClock().Now, subagentNameRandom)
+       defer func() {
+               require.NoError(t, subagents.Close())
+       }()
+
+       subagent, err = subagents.Get(t.Context(), "GreenOtter")
+       require.NoError(t, err)
+
+       again, err := subagents.Get(t.Context(), "GreenOtter")
+       require.NoError(t, err)
+       require.Same(t, subagent, again)
+
+       text, err := subagent.Show()
+       require.NoError(t, err)
+       require.Equal(t, "0|note: subagent\n\n1|user: Count the files.\n\n2|reply: There are 3.", text)
+}
+
+func TestSubagentGetRefusesAnUnknownName(t *testing.T) {
+       subagents := newTestSubagents(t)
+       for _, name := range []string{"BlueFox", "../agent", "Blue"} {
+               _, err := subagents.Get(t.Context(), name)
+               require.EqualError(t, err, "no agent "+name)
+       }
+}
index af500baf0af43c5be067dcb05f3adc8b13de6c86..5329446ff4b3d0ef8627fd7a13140853fb25c360 100644 (file)
--- a/tools.go
+++ b/tools.go
@@ -49,11 +49,12 @@ type ToolChat interface {
 // 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.
+// directory, with no sandbox. Zoom with a name reads the log of a subagent.
 type Toolbox struct {
-       log     ToolLog
-       chat    ToolChat
-       workdir string
+       log       ToolLog
+       chat      ToolChat
+       subagents *Subagents
+       workdir   string
 }
 
 // Call logs the call of the tool name with its JSON arguments, makes the log
@@ -92,7 +93,11 @@ func (b *Toolbox) run(ctx context.Context, name, arguments string) string {
        case "zoom":
                var args ToolZoomArguments
                err = toolDecodeArguments(arguments, &args)
-               if err == nil {
+               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":
@@ -136,6 +141,16 @@ func (b *Toolbox) run(ctx context.Context, name, arguments string) string {
        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) {
@@ -307,10 +322,12 @@ func (b *Toolbox) path(path string) string {
        return filepath.Join(b.workdir, path)
 }
 
-// ToolZoomArguments are the arguments of zoom.
+// 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"`
+       ID   int     `json:"id"`
+       N    int     `json:"n"`
+       Name *string `json:"name"`
 }
 
 // ToolDateArguments are the arguments of date.
index 8960b8f06ad9b2cfbac27c455f1b14388cbf094e..dc08fdc7083bef07f4a0033f56be74458adaf6f6 100644 (file)
@@ -126,9 +126,10 @@ func TestZoomAndDateReadTheChat(t *testing.T) {
        }
 
        box := &Toolbox{
-               log:     memory,
-               chat:    memory,
-               workdir: t.TempDir(),
+               log:       memory,
+               chat:      memory,
+               subagents: newTestSubagents(t),
+               workdir:   t.TempDir(),
        }
 
        testCases := []struct {
@@ -169,6 +170,23 @@ func TestZoomAndDateReadTheChat(t *testing.T) {
        require.Equal(t, "error: message 4 does not exist", box.run(t.Context(), "date", `{"id":4}`))
 }
 
+func TestZoomWithANameGivesTheWholeChatOfTheAgent(t *testing.T) {
+       box := newTestToolbox(t, &ToolRecordingLog{}, t.TempDir())
+       subagent, err := box.subagents.Create(t.Context())
+       require.NoError(t, err)
+
+       for _, message := range [][2]string{{"note", "subagent"}, {"user", "Count the files."}} {
+               _, err = subagent.Append(message[0], message[1])
+               require.NoError(t, err)
+       }
+
+       output := box.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"`+subagent.name+`"}`)
+       require.Equal(t, "0|note: subagent\n\n1|user: Count the files.", output)
+
+       require.Equal(t, "error: no agent BlueFox", box.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"BlueFox"}`))
+       require.Equal(t, "error: line 0+1 goes past the last message, -1", box.run(t.Context(), "zoom", `{"id":0,"n":1,"name":null}`))
+}
+
 func TestClipKeepsTheHeadAndTheTail(t *testing.T) {
        short := strings.Repeat("a", toolClipCharacters)
        require.Equal(t, short, toolClip(short))
@@ -248,8 +266,9 @@ func newTestToolbox(t *testing.T, log ToolLog, dir string) *Toolbox {
        })
 
        return &Toolbox{
-               log:     log,
-               chat:    memory,
-               workdir: dir,
+               log:       log,
+               chat:      memory,
+               subagents: newTestSubagents(t),
+               workdir:   dir,
        }
 }
diff --git a/turn.go b/turn.go
index 051c74df822c2c95b4299e40327f5cf0cad304ee..84f788337e80a7ea79b21461337b62ca68c24141 100644 (file)
--- a/turn.go
+++ b/turn.go
@@ -8,9 +8,10 @@ import (
        "time"
 )
 
-// newTurnLoop returns the loop of agent with its memory. Its calls go through
-// client, and notify gets each event. The clock gives the date of a turn.
-func newTurnLoop(agent *Agent, memory *Memory, client ChainClient, clock func() time.Time, notify func(Event)) (*TurnLoop, error) {
+// newTurnLoop returns the loop of agent with its memory and its subagents. Its
+// calls go through client, and notify gets each event. The clock gives the date
+// of a turn.
+func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, client ChainClient, clock func() time.Time, notify func(Event)) (*TurnLoop, error) {
        layout, err := loadViewLayout(filepath.Join(agent.dir, viewLayoutFileName))
        if err != nil {
                return nil, err
@@ -27,9 +28,10 @@ func newTurnLoop(agent *Agent, memory *Memory, client ChainClient, clock func()
        }
 
        l.tools = &Toolbox{
-               log:     l,
-               chat:    memory,
-               workdir: agent.config.Workdir,
+               log:       l,
+               chat:      memory,
+               subagents: subagents,
+               workdir:   agent.config.Workdir,
        }
 
        return l, nil
index b89a0ec8727e6cc238dc747d61c955adfbb10eaa..928ac334b9dcce7d38c0b357517de80c3597f8c6 100644 (file)
@@ -386,7 +386,7 @@ func newTestTurnLoopWith(t *testing.T, server *ModelFake, summarizer Summarizer)
        }
 
        client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), test.usagePath)
-       test.loop, err = newTurnLoop(agent, memory, client, clock.Now, test.events.Add)
+       test.loop, err = newTurnLoop(agent, memory, newTestSubagents(t), client, clock.Now, test.events.Add)
        require.NoError(t, err)
        ctx, cancel := context.WithCancel(t.Context())
        done := make(chan error)