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",
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
}
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)
})
}
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)
})
}()
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
{
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,
},
}
}
+// 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{
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
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)
}
}
--- /dev/null
+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())
+}
--- /dev/null
+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
+}
--- /dev/null
+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:]
+}
--- /dev/null
+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)
+ }
+}
// 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
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":
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) {
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.
}
box := &Toolbox{
- log: memory,
- chat: memory,
- workdir: t.TempDir(),
+ log: memory,
+ chat: memory,
+ subagents: newTestSubagents(t),
+ workdir: t.TempDir(),
}
testCases := []struct {
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))
})
return &Toolbox{
- log: log,
- chat: memory,
- workdir: dir,
+ log: log,
+ chat: memory,
+ subagents: newTestSubagents(t),
+ workdir: dir,
}
}
"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
}
l.tools = &Toolbox{
- log: l,
- chat: memory,
- workdir: agent.config.Workdir,
+ log: l,
+ chat: memory,
+ subagents: subagents,
+ workdir: agent.config.Workdir,
}
return l, nil
}
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)