From: Anderson A. Giacomolli Date: Sun, 8 Nov 2026 13:23:35 +0000 (-0300) Subject: Store the subagents and read them with zoom X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/6e3e9c5c10f3d35bae7e9846334974a09418aef7?hp=4a931bccab5879ab708647b2df8dadc052f128bf Store the subagents and read them with zoom 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 --- diff --git a/chain_test.go b/chain_test.go index 833f86e..070a5f2 100644 --- a/chain_test.go +++ b/chain_test.go @@ -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", diff --git a/daemon.go b/daemon.go index 2a23dc2..3cef419 100644 --- 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) }) } diff --git a/daemon_start_helper_test.go b/daemon_start_helper_test.go index fd89b34..1952e77 100644 --- a/daemon_start_helper_test.go +++ b/daemon_start_helper_test.go @@ -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) }) }() diff --git a/prompt.go b/prompt.go index c6df057..71671c5 100644 --- 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{ diff --git a/prompt_test.go b/prompt_test.go index 902cb86..5b10597 100644 --- a/prompt_test.go +++ b/prompt_test.go @@ -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 index 0000000..0248751 --- /dev/null +++ b/subagent.go @@ -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 index 0000000..195fc01 --- /dev/null +++ b/subagent_helper_test.go @@ -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 index 0000000..3b9f3a5 --- /dev/null +++ b/subagent_names.go @@ -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 index 0000000..4fab1b1 --- /dev/null +++ b/subagent_test.go @@ -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) + } +} diff --git a/tools.go b/tools.go index af500ba..5329446 100644 --- 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. diff --git a/tools_test.go b/tools_test.go index 8960b8f..dc08fdc 100644 --- a/tools_test.go +++ b/tools_test.go @@ -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 051c74d..84f7883 100644 --- 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 diff --git a/turn_test.go b/turn_test.go index b89a0ec..928ac33 100644 --- a/turn_test.go +++ b/turn_test.go @@ -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)