From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 06:46:17 +0000 (-0300) Subject: Give the root tools to start and message children X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/5a93b6124068c2a8748fc0bfbb2a2d151815bcf0?ds=sidebyside Give the root tools to start and message children The root creates permanent children with its model and effort, sends them durable messages with tell, and reads their views with zoom. A child has a short capitalized name for its area, such as House, which no child, subagent, or the root uses. spawn keeps its short tasks. Children have no creation or WhatsApp tools. The root sees each child name and area in its turn state, while its shared prompt stays stable. The prompt directs mixed-chat area messages to the right child with their sender, date, and words, and requires user agreement before the root sends a child draft to WhatsApp. A new child is prepared outside the children directory and published in one step, so a stop never leaves a half-made child that blocks the next start, and a failed creation frees its name. Runtime children join the daemon lifetime and backup locks before they receive tasks. Issue: 63b101c --- diff --git a/README.rst b/README.rst index b413c2b..556ab46 100644 --- a/README.rst +++ b/README.rst @@ -139,12 +139,17 @@ propose a change to the file, and it makes the change only after the user agrees. A permanent child agent lives in ``agent/children/NAME/`` with the same -files as the root. Create its directory and ``config.json`` by hand -before startup. Use the root config fields with a separate random ID, -set ``name`` to ``NAME``, and add ``parent`` with the root name and -``responsibility`` with the child's area. The daemon opens all children -at startup. A missing or invalid child config stops startup. Only one -level of children is supported. +files as the root. The root's ``agent_create(name, responsibility)`` +tool creates and starts it with the root's model, effort, and working +directory, and a separate random ID. A name describes the area, such as +``House`` or ``Calendar``. It is one word of 2 to 32 ASCII letters, +starts with a capital letter, and cannot name the root, an existing +child, or a subagent. The daemon opens all children at startup. A +missing or invalid child config stops startup. Only one level of +children is supported. Remove a child by hand while the daemon is +stopped. The root's turn state lists each child and its responsibility. +The root passes area messages from mixed chats with sender, date, and +words, and asks the user before it sends a child's draft to WhatsApp. Each child has its own memory, model client, usage file, subagents, schedules, and turn loop. Its prompt limits work to its area, asks for @@ -162,8 +167,10 @@ next turn. ``agent/messages.json``, with mode 0600, keeps messages until their recipient finishes its turn. A daemon stop preserves queued messages and reports for the next start without a second log entry. An interrupted turn can repeat its model calls and tools after restart. -Backups lock this queue with the agent files. No tools send messages -between parent and child agents yet. +Backups lock this queue with the agent files, including children made +during the daemon run. The root uses ``tell`` for both children and +subagents. ``zoom("Name")`` gives a child's view or a subagent's whole +log. ``spawn`` starts short-lived subagents, not permanent children. ************** User service diff --git a/agent.go b/agent.go index 274806b..9a85426 100644 --- a/agent.go +++ b/agent.go @@ -12,6 +12,8 @@ import ( "path/filepath" "slices" "strings" + + "golang.org/x/sys/unix" ) const ( @@ -105,12 +107,12 @@ func (a *Agent) Prompt() (string, error) { } // Tools returns the tools shared by the turns and compactions of this agent. A -// child has no WhatsApp tools. +// child has neither WhatsApp tools nor child creation. func (a *Agent) Tools() []ModelTool { tools := promptTools() if a.config.Parent != "" { tools = slices.DeleteFunc(tools, func(tool ModelTool) bool { - return strings.HasPrefix(tool.Name, "whatsapp_") + return strings.HasPrefix(tool.Name, "whatsapp_") || tool.Name == "agent_create" }) } @@ -176,6 +178,81 @@ func (a *Agent) Children() ([]*Agent, error) { return children, nil } +// CreateChild writes a new direct child with its own ID and the model, effort, +// and working directory of the root. Child and subagent names share a +// namespace. The config is complete before an atomic rename publishes the +// child. Preparation stays outside children, so an interrupted write cannot +// stop startup. +func (a *Agent) CreateChild(name, responsibility string, subagents *Subagents) (_ *Agent, err error) { + if a.config.Parent != "" { + return nil, errors.New("only the root can create children") + } + + if !agentChildNameValid(name) { + return nil, errors.New("name must have 2 to 32 ASCII letters and start with a capital letter") + } + + if name == a.config.Name { + return nil, errors.New("the name is already used by the root") + } + + responsibility = strings.TrimSpace(responsibility) + if responsibility == "" { + return nil, errors.New("responsibility must not be empty") + } + + err = subagents.Reserve(name) + if err != nil { + return nil, fmt.Errorf("reserve child name: %w", err) + } + + published := false + defer func() { + if !published { + subagents.Release(name) + } + }() + + staged, err := os.MkdirTemp(a.dir, ".child-*") + if err != nil { + return nil, fmt.Errorf("prepare child directory: %w", err) + } + + defer func() { + cleanupErr := os.RemoveAll(staged) + if cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("remove prepared child: %w", cleanupErr)) + } + }() + + config := a.config + config.Name = name + config.ID = "" + config.Parent = a.config.Name + config.Responsibility = responsibility + child, err := writeAgent(staged, config) + if err != nil { + return nil, fmt.Errorf("write child: %w", err) + } + + dir := filepath.Join(a.dir, "children") + err = os.MkdirAll(dir, 0o700) + if err != nil { + return nil, fmt.Errorf("create children directory: %w", err) + } + + dir = filepath.Join(dir, name) + err = unix.Renameat2(unix.AT_FDCWD, staged, unix.AT_FDCWD, dir, unix.RENAME_NOREPLACE) + if err != nil { + return nil, fmt.Errorf("publish child directory: %w", err) + } + + published = true + child.dir = dir + + return child, nil +} + // SubagentPrompt returns the system prompt of a subagent of the agent, with the // user's instructions as Prompt reads them. func (a *Agent) SubagentPrompt() (string, error) { @@ -243,6 +320,23 @@ func (c AgentConfig) validate() error { return nil } +// agentChildNameValid accepts one word of 2 to 32 ASCII letters with an initial +// capital letter. Child names describe their area. +func agentChildNameValid(name string) bool { + if len(name) < 2 || len(name) > 32 || name[0] < 'A' || name[0] > 'Z' { + return false + } + + for i := 1; i < len(name); i++ { + letter := name[i] + if (letter < 'A' || letter > 'Z') && (letter < 'a' || letter > 'z') { + return false + } + } + + return true +} + // createAgent writes the config.json of a new agent in dir with the defaults // and a new ID. func createAgent(dir string) (*Agent, error) { @@ -251,8 +345,13 @@ func createAgent(dir string) (*Agent, error) { return nil, err } + return writeAgent(dir, config) +} + +// writeAgent gives config a new ID and writes it in dir. +func writeAgent(dir string, config AgentConfig) (*Agent, error) { id := make([]byte, agentIDBytes) - _, err = rand.Read(id) + _, err := rand.Read(id) if err != nil { return nil, fmt.Errorf("make agent ID: %w", err) } diff --git a/daemon.go b/daemon.go index 958d6f7..2a93f1d 100644 --- a/daemon.go +++ b/daemon.go @@ -370,10 +370,16 @@ func daemonServe(ctx context.Context, listener net.Listener, bot *TelegramBot, w } hub.snapshot = func(ctx context.Context, snapshot func() error) error { - return messages.Snapshot(func() error { - return daemonSnapshot(ctx, loops, snapshot) - }) + return messages.SnapshotAgents(ctx, snapshot) + } + + runtime := &DaemonChildren{ + ctx: ctx, + cancel: cancel, + root: l, + messages: messages, } + l.tools.children = runtime var workers sync.WaitGroup @@ -449,7 +455,7 @@ func daemonServe(ctx context.Context, listener net.Listener, bot *TelegramBot, w hub.CloseAll() workers.Wait() - return errors.Join(err, whatsappErr, schedulesErr, botErr, errors.Join(childErrors...)) + return errors.Join(err, whatsappErr, schedulesErr, botErr, errors.Join(childErrors...), runtime.Close()) } // daemonRunChild runs a child's turn loop and its schedules until either stops. diff --git a/daemon_agent.go b/daemon_agent.go index 4520f35..662291c 100644 --- a/daemon_agent.go +++ b/daemon_agent.go @@ -73,6 +73,9 @@ func openDaemonAgent(ctx context.Context, agent *Agent, address string, auth Mod schedules: schedules, clock: clock, logger: logger, + openChild: func(ctx context.Context, child *Agent) (*DaemonAgent, error) { + return openDaemonAgent(ctx, child, address, auth, compactor, clock, logger.With("name", child.config.Name)) + }, }, nil } @@ -86,12 +89,20 @@ type DaemonAgent struct { schedules *Schedules clock func() time.Time logger *slog.Logger + openChild func(context.Context, *Agent) (*DaemonAgent, error) } // Loop makes the turn loop with its event destination. Only the root has a // WhatsApp link. Child events stay with their own logs. func (a *DaemonAgent) Loop(whatsapp *WhatsApp, notify func(Event)) (*TurnLoop, error) { - return newTurnLoop(a.agent, a.memory, a.subagents, whatsapp, a.schedules, a.client, a.clock, a.logger, notify) + loop, err := newTurnLoop(a.agent, a.memory, a.subagents, whatsapp, a.schedules, a.client, a.clock, a.logger, notify) + if err != nil { + return nil, err + } + + loop.openChild = a.openChild + + return loop, nil } // Close flushes and closes the subagents and the memory. diff --git a/daemon_children.go b/daemon_children.go new file mode 100644 index 0000000..ddb14c0 --- /dev/null +++ b/daemon_children.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "errors" + "fmt" + "sync" +) + +// DaemonChildren starts children made by the root during a daemon run. The root +// calls Create in its turn goroutine. Close runs only after that loop stops. +// The daemon context, not the calling turn, owns each child's lifetime. +type DaemonChildren struct { + ctx context.Context + cancel context.CancelFunc + root *TurnLoop + messages *DaemonMessages + opened DaemonAgents + workers sync.WaitGroup + mutex sync.Mutex + failure error +} + +// Create makes and opens a child, registers its queue, and starts its loop. +// Registration holds the queue lock through file creation, so a backup cannot +// copy a child before its open files are part of the snapshot. +func (c *DaemonChildren) Create(name, responsibility string) (string, error) { + if c.ctx.Err() != nil { + return "", c.ctx.Err() + } + + if c.root.openChild == nil { + return "", errors.New("child creation is not available") + } + + var opened *DaemonAgent + + loop, err := c.messages.AddChild(c.root.agent.config.Name, name, func() (*TurnLoop, error) { + agent, err := c.root.agent.CreateChild(name, responsibility, c.root.runs.subagents) + if err != nil { + return nil, fmt.Errorf("create child config: %w", err) + } + + opened, err = c.root.openChild(c.ctx, agent) + if err != nil { + return nil, fmt.Errorf("open child: %w", err) + } + + loop, err := opened.Loop(nil, func(Event) { + }) + if err != nil { + return nil, errors.Join(fmt.Errorf("open child loop: %w", err), opened.Close()) + } + + return loop, nil + }) + if err != nil { + return "", fmt.Errorf("start child %q: %w", name, err) + } + + c.opened = append(c.opened, opened) + c.workers.Add(1) + + go func() { + defer c.workers.Done() + + err := daemonRunChild(c.ctx, loop, opened.schedules) + if err != nil { + opened.logger.Error("child stopped", "err", err) + c.mutex.Lock() + + c.failure = errors.Join(c.failure, err) + c.mutex.Unlock() + } + + c.cancel() + }() + + c.root.logger.Info("child started", "name", name) + + return "Started " + name + ". Use tell to send it a task.", nil +} + +// Close waits for the runtime children, closes their files, and returns their +// failures. The caller stops the daemon context before this call. +func (c *DaemonChildren) Close() error { + c.workers.Wait() + + return errors.Join(c.failure, c.opened.Close()) +} diff --git a/daemon_children_test.go b/daemon_children_test.go new file mode 100644 index 0000000..16f326f --- /dev/null +++ b/daemon_children_test.go @@ -0,0 +1,467 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestDaemonToolsCreateAndTellAChildDuringATurn(t *testing.T) { + dir := t.TempDir() + server := newModelFake(t) + agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, discardLog()) + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, agents.Close()) + }) + + root := agents[0] + next := 0 + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + if call.body["prompt_cache_key"] != root.agent.CacheKey() { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Approve the gate size.")}, 100, 0, 5)) + + return + } + + next++ + + var items []any + + switch next { + case 1: + items = []any{modelFunctionCallItem("fc_1", "call_1", "agent_create", `{"name":"House","responsibility":"Keep garden decisions."}`)} + case 2: + items = []any{modelFunctionCallItem("fc_2", "call_2", "tell", `{"name":"House","message":"Check the gate size."}`)} + case 3: + items = []any{modelFunctionCallItem("fc_3", "call_3", "zoom", `{"id":0,"n":1,"name":"House"}`)} + default: + items = []any{modelMessageItem("I will ask the user.")} + } + + modelWriteEvents(w, modelCompletedEvent(items, 100, 0, 5)) + } + whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock()) + listener, err := daemonListenSocket(filepath.Join(dir, socketFileName)) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- daemonServe(ctx, listener, nil, whatsapp, root.schedules, func(notify func(Event)) (*TurnLoop, error) { + return root.Loop(whatsapp, notify) + }) + }() + + t.Cleanup(func() { + cancel() + require.NoError(t, <-done) + }) + + client := terminalStartTestClient(t, listener.Addr().String()) + client.WaitFor(t, "[connected to OneChat]") + client.Type("Create a child and ask it about the gate.") + client.WaitFor(t, "[House] Approve the gate size.") + client.WaitFor(t, "I will ask the user.") + require.Contains(t, client.Output(), "Started House.") + require.Contains(t, client.Output(), "user: Check the gate size.") + + child, err := openAgent(filepath.Join(root.agent.dir, "children", "House")) + require.NoError(t, err) + require.Equal(t, root.agent.config.Model, child.config.Model) + require.Equal(t, root.agent.config.Effort, child.config.Effort) + require.Equal(t, root.agent.config.Workdir, child.config.Workdir) + require.Equal(t, root.agent.config.Name, child.config.Parent) + require.NotEqual(t, root.agent.CacheKey(), child.CacheKey()) + + client.Type("Show the garden responsibility.") + require.Eventually(t, func() bool { + for i := range server.Calls() { + call := server.Call(i) + texts := turnInputTexts(t, call) + if len(texts) > 0 && texts[len(texts)-1] == "Show the garden responsibility." { + return strings.Contains(strings.Join(texts, "\n"), "Children:\nHouse: Keep garden decisions.") + } + } + + return false + }, 5*time.Second, time.Millisecond) +} + +func TestChildCreationRefusesUsedNames(t *testing.T) { + test := newDaemonChildrenTest(t) + box := test.root.tools + require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"OneChat","responsibility":"Other plans."}`), "already used") + + require.NoDirExists(t, filepath.Join(test.root.agent.dir, "children")) + require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":" "}`), "responsibility") + require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":"Keep garden plans."}`), "Started House.") + require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":"Other plans."}`), "already used") + + test.root.runs.subagents.draw = func() string { + return "BlueFox" + } + subagent, err := test.root.runs.subagents.Create(t.Context()) + require.NoError(t, err) + require.Equal(t, "BlueFox", subagent.name) + require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"BlueFox","responsibility":"Other plans."}`), "already used by a subagent") + require.NoError(t, test.root.runs.subagents.Reserve("Calendar")) + require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"Calendar","responsibility":"Keep dates."}`), "already used") +} + +func TestChildCreationAcceptsAreaNames(t *testing.T) { + testCases := []struct { + name string + child string + }{ + { + name: "house area", + child: "House", + }, + { + name: "calendar area", + child: "Calendar", + }, + { + name: "two letters", + child: "Ab", + }, + { + name: "capital letters after the first", + child: "ABcD", + }, + { + name: "thirty-two letters", + child: "A" + strings.Repeat("b", 31), + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + test := newDaemonChildrenTest(t) + arguments, err := json.Marshal(ToolAgentCreateArguments{ + Name: tc.child, + Responsibility: "Keep area decisions.", + }) + require.NoError(t, err) + require.Contains(t, test.root.tools.run(t.Context(), "agent_create", string(arguments)), "Started "+tc.child+".") + + child := test.runtime.messages.Child(test.root.agent.config.Name, tc.child) + require.NotNil(t, child) + require.Equal(t, tc.child, child.agent.config.Name) + require.FileExists(t, filepath.Join(child.agent.dir, "config.json")) + }) + } +} + +func TestChildCreationRefusesInvalidAreaNames(t *testing.T) { + testCases := []struct { + name string + child string + }{ + { + name: "empty name", + child: "", + }, + { + name: "one letter", + child: "H", + }, + { + name: "thirty-three letters", + child: "A" + strings.Repeat("b", 32), + }, + { + name: "lowercase initial", + child: "house", + }, + { + name: "two words", + child: "My House", + }, + { + name: "digit", + child: "House2", + }, + { + name: "path", + child: "../House", + }, + { + name: "punctuation", + child: "House-", + }, + { + name: "non-ASCII initial", + child: "\u00c1rea", + }, + { + name: "non-ASCII remainder", + child: "Hous\u00e9", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + test := newDaemonChildrenTest(t) + arguments, err := json.Marshal(ToolAgentCreateArguments{ + Name: tc.child, + Responsibility: "Keep area decisions.", + }) + require.NoError(t, err) + require.Contains(t, test.root.tools.run(t.Context(), "agent_create", string(arguments)), "name must have 2 to 32 ASCII letters and start with a capital letter") + require.NoDirExists(t, filepath.Join(test.root.agent.dir, "children")) + }) + } +} + +func TestFailedChildPublicationKeepsStartupAndRetryWorking(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("requires directory write permissions") + } + + dir := t.TempDir() + root, err := openAgent(filepath.Join(dir, daemonAgentDirName)) + require.NoError(t, err) + + subagents := newSubagents(filepath.Join(root.dir, subagentDirName), time.Now, subagentNameRandom) + defer func() { + require.NoError(t, subagents.Close()) + }() + + children := filepath.Join(root.dir, "children") + require.NoError(t, os.Mkdir(children, 0o500)) + t.Cleanup(func() { + require.NoError(t, os.Chmod(children, 0o700)) + }) + + _, err = root.CreateChild("GreenOtter", "Keep garden plans.", subagents) + require.ErrorContains(t, err, "publish child directory") + require.NoDirExists(t, filepath.Join(children, "GreenOtter")) + + staged, err := filepath.Glob(filepath.Join(root.dir, ".child-*")) + require.NoError(t, err) + require.Empty(t, staged) + + server := newModelFake(t) + reopened, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog()) + require.NoError(t, err) + require.Len(t, reopened, 1) + + defer func() { + require.NoError(t, reopened.Close()) + }() + + require.NoError(t, os.Chmod(children, 0o700)) + + child, err := root.CreateChild("GreenOtter", "Keep garden plans.", subagents) + require.NoError(t, err) + require.Equal(t, filepath.Join(children, "GreenOtter"), child.dir) +} + +func TestInterruptedChildPreparationDoesNotStopStartup(t *testing.T) { + dir := t.TempDir() + root, err := openAgent(filepath.Join(dir, daemonAgentDirName)) + require.NoError(t, err) + + staged, err := os.MkdirTemp(root.dir, ".child-*") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(staged, ".config.json.partial"), []byte("{"), 0o600)) + + server := newModelFake(t) + agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog()) + require.NoError(t, err) + + defer func() { + require.NoError(t, agents.Close()) + }() + + require.Len(t, agents, 1) + + child, err := root.CreateChild("GreenOtter", "Keep garden plans.", agents[0].subagents) + require.NoError(t, err) + + children, err := root.Children() + require.NoError(t, err) + require.Len(t, children, 1) + require.Equal(t, child.config, children[0].config) + require.DirExists(t, staged) +} + +func TestChildPublicationDoesNotReplaceAnExistingDirectory(t *testing.T) { + test := newDaemonChildrenTest(t) + path := filepath.Join(test.root.agent.dir, "children", "GreenOtter") + require.NoError(t, os.MkdirAll(path, 0o700)) + + _, err := test.runtime.Create("GreenOtter", "Keep garden plans.") + require.ErrorContains(t, err, "publish child directory") + require.NoFileExists(t, filepath.Join(path, "config.json")) + require.NoError(t, test.root.runs.subagents.Reserve("GreenOtter")) + test.root.runs.subagents.Release("GreenOtter") +} + +func TestChildNamesStayOutOfSubagentDraws(t *testing.T) { + test := newDaemonChildrenTest(t) + _, err := test.runtime.Create("GreenOtter", "Keep garden plans.") + require.NoError(t, err) + + names := []string{"GreenOtter", "BlueFox"} + test.root.runs.subagents.draw = func() string { + name := names[0] + names = names[1:] + + return name + } + subagent, err := test.root.runs.subagents.Create(t.Context()) + require.NoError(t, err) + require.Equal(t, "BlueFox", subagent.name) +} + +func TestChildToolsExcludeCreationAndWhatsApp(t *testing.T) { + test := newDaemonChildrenTest(t) + _, err := test.runtime.Create("GreenOtter", "Keep garden plans.") + require.NoError(t, err) + + child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter") + require.NotNil(t, child) + + names := promptToolNames(child.agent.Tools()) + require.NotContains(t, names, "agent_create") + require.NotContains(t, names, "whatsapp_send") + require.NotContains(t, names, "whatsapp_find") + require.Contains(t, names, "spawn") + require.Contains(t, names, "schedule_set") + require.Contains(t, names, "schedule_list") + require.Contains(t, names, "schedule_remove") + require.Equal(t, `error: unknown tool "agent_create"`, child.tools.run(t.Context(), "agent_create", `{"name":"BlueFox","responsibility":"Other plans."}`)) + require.Nil(t, child.tools.messages.Child("GreenOtter", "OneChat")) +} + +func TestZoomReadsTheChildViewAndKeepsSubagentLogs(t *testing.T) { + test := newDaemonChildrenTest(t) + _, err := test.runtime.Create("GreenOtter", "Keep garden plans.") + require.NoError(t, err) + + child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter") + _, err = child.memory.Append("note", "The gate is narrow.") + require.NoError(t, err) + require.NoError(t, child.memory.Settle(t.Context())) + + output := test.root.tools.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"GreenOtter"}`) + require.Equal(t, strings.Join(child.memory.ViewLines(nil), ""), output) + require.Contains(t, output, "note: The gate is narrow.") + + test.root.runs.subagents.draw = func() string { + return "BlueFox" + } + subagent, err := test.root.runs.subagents.Create(t.Context()) + require.NoError(t, err) + + _, err = subagent.Append("note", "A short task.") + require.NoError(t, err) + require.Equal(t, "0|note: A short task.", test.root.tools.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"BlueFox"}`)) +} + +func TestRuntimeChildJoinsBackupLocksAndStopsWithDaemon(t *testing.T) { + test := newDaemonChildrenTest(t) + _, err := test.runtime.Create("GreenOtter", "Keep garden plans.") + require.NoError(t, err) + + child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter") + _, err = child.memory.Append("note", "Keep the gate plan.") + require.NoError(t, err) + + appended := make(chan error, 1) + require.NoError(t, test.runtime.messages.SnapshotAgents(t.Context(), func() error { + require.FileExists(t, filepath.Join(child.agent.dir, "view.json")) + + go func() { + _, err := child.memory.Append("note", "A later plan.") + appended <- err + }() + + select { + case err := <-appended: + require.FailNow(t, "child append bypasses snapshot lock", "%v", err) + case <-time.After(20 * time.Millisecond): + } + + return nil + })) + require.NoError(t, <-appended) + test.cancel() + require.NoError(t, test.runtime.Close()) + + test.runtime.opened = nil + reopened, err := test.root.openChild(t.Context(), child.agent) + require.NoError(t, err) + require.Equal(t, 2, reopened.memory.Len()) + require.NoError(t, reopened.Close()) +} + +func TestRootStateListsChildResponsibilitiesOnlyWhenPresent(t *testing.T) { + test := newDaemonChildrenTest(t) + before := test.root.state() + require.NotContains(t, before, "Children:") + + _, err := test.runtime.Create("GreenOtter", "Keep garden plans.") + require.NoError(t, err) + + _, err = test.runtime.Create("BlueFox", "Keep book notes.") + require.NoError(t, err) + require.Equal(t, before+"\n\nChildren:\nBlueFox: Keep book notes.\nGreenOtter: Keep garden plans.", test.root.state()) + + child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter") + require.NotContains(t, child.state(), "Children:") +} + +// DaemonChildrenTest owns one root and runtime children without a root turn. +type DaemonChildrenTest struct { + root *TurnLoop + runtime *DaemonChildren + server *ModelFake + cancel context.CancelFunc +} + +func newDaemonChildrenTest(t *testing.T) *DaemonChildrenTest { + t.Helper() + + test := &DaemonChildrenTest{ + server: newModelFake(t), + } + agents, err := openDaemonAgents(t.Context(), t.TempDir(), test.server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog()) + require.NoError(t, err) + + test.root, err = agents[0].Loop(nil, func(Event) { + }) + require.NoError(t, err) + + messages, err := openDaemonMessages([]*TurnLoop{test.root}) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + test.cancel = cancel + test.runtime = &DaemonChildren{ + ctx: ctx, + cancel: cancel, + root: test.root, + messages: messages, + } + test.root.tools.children = test.runtime + t.Cleanup(func() { + cancel() + require.NoError(t, test.runtime.Close()) + test.root.runs.Close() + require.NoError(t, agents.Close()) + }) + + return test +} diff --git a/daemon_message.go b/daemon_message.go index db15db8..8415e49 100644 --- a/daemon_message.go +++ b/daemon_message.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "errors" "fmt" @@ -16,17 +17,26 @@ import ( // finishes the turn that takes it, so a stop can interrupt delivery or a turn. func openDaemonMessages(loops []*TurnLoop) (*DaemonMessages, error) { m := &DaemonMessages{ - path: filepath.Join(loops[0].agent.dir, "messages.json"), - loops: make(map[string]*TurnLoop), + path: filepath.Join(loops[0].agent.dir, "messages.json"), + ordered: append([]*TurnLoop(nil), loops...), + loops: make(map[string]*TurnLoop), } for _, loop := range loops { m.loops[loop.agent.config.Name] = loop loop.messages = m + loop.tools.messages = m if loop.agent.config.Parent == "" { loop.logger = loop.logger.With("name", loop.agent.config.Name) } } + for _, child := range loops[1:] { + err := loops[0].runs.subagents.Reserve(child.agent.config.Name) + if err != nil { + return nil, fmt.Errorf("reserve child name: %w", err) + } + } + data, err := os.ReadFile(m.path) if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("read agent messages: %w", err) @@ -76,6 +86,7 @@ type DaemonMessages struct { mutex sync.Mutex path string loops map[string]*TurnLoop + ordered []*TurnLoop state DaemonMessageFile failure error } @@ -125,6 +136,83 @@ func (m *DaemonMessages) save() error { return nil } +// AddChild holds the queue lock while open makes a child's files and loop. The +// loop joins backups before its first task can be delivered. +func (m *DaemonMessages) AddChild(parent, name string, open func() (*TurnLoop, error)) (*TurnLoop, error) { + m.mutex.Lock() + defer m.mutex.Unlock() + + if m.loops[name] != nil { + return nil, fmt.Errorf("name %q is already used", name) + } + + root := m.loops[parent] + if root == nil || root.agent.config.Parent != "" { + return nil, errors.New("only the root can create children") + } + + if m.failure != nil { + return nil, fmt.Errorf("agent message queue stopped: %w", m.failure) + } + + loop, err := open() + if err != nil { + return nil, fmt.Errorf("open child: %w", err) + } + + loop.messages = m + loop.tools.messages = m + m.loops[name] = loop + m.ordered = append(m.ordered, loop) + + return loop, nil +} + +// Child returns only a direct child of parent. Loops live until the daemon +// stops, so the returned loop stays open for the caller's turn. +func (m *DaemonMessages) Child(parent, name string) *TurnLoop { + m.mutex.Lock() + defer m.mutex.Unlock() + + loop := m.loops[name] + if loop == nil || loop.agent.config.Parent != parent { + return nil + } + + return loop +} + +// ChildState lists the names and responsibilities of a parent's children in +// name order. It returns no heading when the parent has no children. +func (m *DaemonMessages) ChildState(parent string) string { + m.mutex.Lock() + defer m.mutex.Unlock() + + var lines []string + + for name, loop := range m.loops { + if loop.agent.config.Parent == parent { + lines = append(lines, name+": "+loop.agent.config.Responsibility) + } + } + + if len(lines) == 0 { + return "" + } + + slices.Sort(lines) + + return "\n\nChildren:\n" + strings.Join(lines, "\n") +} + +// SnapshotAgents locks the queue and all registered loops, including children +// made during this daemon run, before snapshot copies their files. +func (m *DaemonMessages) SnapshotAgents(ctx context.Context, snapshot func() error) error { + return m.Snapshot(func() error { + return daemonSnapshot(ctx, m.ordered, snapshot) + }) +} + // Tell queues a user message from parent to its own child, then wakes the // child. It is the daemon entry point for parent-to-child messages. func (m *DaemonMessages) Tell(parent, child, text string) error { diff --git a/prompt.go b/prompt.go index 934491f..6bc737d 100644 --- a/prompt.go +++ b/prompt.go @@ -101,11 +101,11 @@ starting "[Name]", between your tool calls or as a new turn. Never wait for one running. You are in persistent mode: a reply with no tool call is your final answer, and -it ends your turn. You act again only when the user writes. So keep going until -the user's task is done, or until only the user can unblock it. Answer a -question asked in the middle of a task, then go on with the task in the same -turn. When the user says "continue", resume the latest unfinished task from the -view. +it ends your turn. You act again when a message or your own schedule wakes you. +So keep going until the user's task is done, or until only the user can unblock +it. Answer a question asked in the middle of a task, then go on with the task +in the same turn. When the user says "continue", resume the latest unfinished +task from the view. # Compactions @@ -142,6 +142,15 @@ exactly. Tag each item with its kind ("user: ...; echo: ..."), and credit quoted text to its real author. Never make anything look further along than it was. If told the line is too long, shorten it. Non-ASCII characters cost 2-4 bytes.` +// promptChildrenText gives the root the tools and consent rules for children. +const promptChildrenText = ` + +Use agent_create for permanent children, whose areas are in the state. +Use tell for children and subagents, and zoom("Name") for their memory. +Pass area messages from mixed chats to the right child with sender, date, and +words. Never send a WhatsApp reply drafted by a child without the user's +agreement.` + // promptSubagentText is the system prompt of a subagent, from the subagent // prompt of the reference design: its head, then the doc of the view, without // the parts on devices and images. A subagent gets the view of the agent, then @@ -188,6 +197,7 @@ whole chat.` // cache of the turns. func prompt(name, instructionsPath, instructions string) string { text := strings.NewReplacer(promptName, name, promptInstructionsPath, instructionsPath).Replace(promptText) + text = strings.Replace(text, "\n\n# Compactions", promptChildrenText+"\n\n# Compactions", 1) return promptWithInstructions(text, promptTaskSentence, promptInstructionsClause, instructions) } @@ -205,7 +215,7 @@ message outside your area. Your final reply is your report to the parent. End your turn with an empty reply when nothing needs the parent or the user. Send nothing to WhatsApp. Draft replies in your report instead, so the parent can ask the user before sending.`, 1) - text = strings.Replace(text, "You act again only when the user writes.", "You act again when a message or your own schedule wakes you.", 1) + text = strings.Replace(text, promptChildrenText, "", 1) return promptWithInstructions(text, "Do the tasks of your area yourself, with your tools.", promptInstructionsClause, instructions) } @@ -219,9 +229,9 @@ func promptSubagent(name, instructions string) string { } // promptTools returns the function tools of every call of the agent: the tools -// of promptSubagentTools, then spawn, tell, the WhatsApp tools, and the -// schedule tools. A compaction sends them too and never calls them, because a -// change of the tools ends the shared prompt cache. +// of promptSubagentTools, then spawn, agent_create, tell, the WhatsApp tools, +// and the schedule tools. A compaction sends them too and never calls them, +// because a change of the tools ends the shared prompt cache. func promptTools() []ModelTool { return append(promptSubagentTools(), ModelTool{ Type: "function", @@ -240,12 +250,21 @@ func promptTools() []ModelTool { }, }), Strict: true, + }, ModelTool{ + Type: "function", + Name: "agent_create", + Description: "Create and start a permanent child for an area, with your model and effort. Give a short capitalized name for the area, such as House, with 2 to 32 ASCII letters. Use tell to give it a task. Only the root can create children.", + Parameters: promptObjectSchema(map[string]any{ + "name": promptStringSchema("The new child name."), + "responsibility": promptStringSchema("The area that the child owns."), + }), + Strict: true, }, ModelTool{ Type: "function", Name: "tell", - Description: `Send a message to a subagent, by name. One at work reads it between its tool calls, and its report answers it; a finished one goes on from its chat, and its reply reaches you as a message "[Name] reply".`, + Description: `Send a message to your child or a subagent, by name. A child wakes and reports to you. One at work reads it between its tool calls, and its report answers it; a finished one goes on from its chat, and its reply reaches you as a message "[Name] reply".`, Parameters: promptObjectSchema(map[string]any{ - "name": promptStringSchema("The name of the subagent."), + "name": promptStringSchema("The name of your child or subagent."), "message": promptStringSchema("The message."), }), Strict: true, diff --git a/prompt_helper_test.go b/prompt_helper_test.go new file mode 100644 index 0000000..5114036 --- /dev/null +++ b/prompt_helper_test.go @@ -0,0 +1,11 @@ +package main + +// promptToolNames returns the names of tools. +func promptToolNames(tools []ModelTool) []string { + names := make([]string, len(tools)) + for n, tool := range tools { + names[n] = tool.Name + } + + return names +} diff --git a/prompt_test.go b/prompt_test.go index ba02b23..0aa9adb 100644 --- a/prompt_test.go +++ b/prompt_test.go @@ -128,7 +128,7 @@ func TestProbePromptKeepsTheInstructionsPathStable(t *testing.T) { func TestAgentToolsAreStrictAndStable(t *testing.T) { tools := promptTools() - require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "tell", "whatsapp_find", "whatsapp_send", "schedule_set", "schedule_list", "schedule_remove"}, promptToolNames(tools)) + require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "agent_create", "tell", "whatsapp_find", "whatsapp_send", "schedule_set", "schedule_list", "schedule_remove"}, promptToolNames(tools)) require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, promptToolNames(promptSubagentTools())) for _, tool := range tools { @@ -150,12 +150,17 @@ func TestAgentToolsAreStrictAndStable(t *testing.T) { } } -// promptToolNames returns the names of tools. -func promptToolNames(tools []ModelTool) []string { - names := make([]string, len(tools)) - for n, tool := range tools { - names[n] = tool.Name - } - - return names +func TestRootPromptDefinesChildRoutingAndConsent(t *testing.T) { + text := prompt("Ada", "/AGENT/instructions.md", "Keep replies short.") + require.Contains(t, text, "Use agent_create for permanent children, whose areas are in the state.") + require.Contains(t, text, `Use tell for children and subagents, and zoom("Name") for their memory.`) + require.Contains(t, text, "Pass area messages from mixed chats to the right child with sender, date, and\nwords.") + require.Contains(t, text, "Never send a WhatsApp reply drafted by a child without the user's\nagreement.") + require.Contains(t, text, "You act again when a message or your own schedule wakes you.") + require.True(t, strings.HasSuffix(text, "Keep replies short.")) + + child := promptChild("GreenOtter", "Ada", "Keep garden plans.", "/CHILD/instructions.md", "") + require.NotContains(t, child, promptChildrenText) + require.NotContains(t, child, "Use agent_create") + require.Contains(t, child, "Send nothing to WhatsApp. Draft replies in your report instead") } diff --git a/subagent.go b/subagent.go index 07bfa45..4cb6381 100644 --- a/subagent.go +++ b/subagent.go @@ -28,10 +28,11 @@ const ( // 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), + dir: dir, + clock: clock, + draw: draw, + open: make(map[string]*Subagent), + reserved: make(map[string]bool), } } @@ -44,8 +45,9 @@ type Subagents struct { clock func() time.Time draw func() string - mutex sync.Mutex - open map[string]*Subagent + mutex sync.Mutex + open map[string]*Subagent + reserved map[string]bool } // Create makes the directory and the log of a new subagent. It draws a name @@ -58,6 +60,15 @@ func (s *Subagents) Create(ctx context.Context) (*Subagent, error) { for range subagentNameDraws { name := s.draw() + s.mutex.Lock() + + reserved := s.reserved[name] + s.mutex.Unlock() + + if reserved { + continue + } + err = os.Mkdir(filepath.Join(s.dir, name), 0o700) if errors.Is(err, fs.ErrExist) { continue @@ -73,6 +84,39 @@ func (s *Subagents) Create(ctx context.Context) (*Subagent, error) { return nil, fmt.Errorf("no free subagent name after %d draws", subagentNameDraws) } +// Reserve keeps a child name out of future subagent draws. An existing subagent +// directory or a reserved name is refused. +func (s *Subagents) Reserve(name string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.reserved[name] { + return fmt.Errorf("name %q is already used", name) + } + + _, err := os.Lstat(filepath.Join(s.dir, name)) + if err == nil { + return fmt.Errorf("name %q is already used by a subagent", name) + } + + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("check subagent name: %w", err) + } + + s.reserved[name] = true + + return nil +} + +// Release frees a reservation after a child fails to publish. It does not +// remove a subagent directory or change an open log. +func (s *Subagents) Release(name string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + delete(s.reserved, name) +} + // 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) { diff --git a/tools.go b/tools.go index 0811981..b4fb075 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. Zoom with a name reads the log of a subagent. The -// toolbox of the agent has the runs of its subagents for spawn and tell, the -// WhatsApp link for the WhatsApp tools, and the schedules for the schedule -// tools. A child has no WhatsApp link. The toolbox of a subagent has none of -// them, so these are unknown tools there. +// directory, with no sandbox. Zoom with a name reads a child's view or a +// subagent's log. The toolbox of the agent has the runs of its subagents for +// spawn and tell, the WhatsApp link for the WhatsApp tools, and the schedules +// for the schedule tools. Each permanent agent has the parent-child queue. Only +// the root has child creation. A child has no WhatsApp link or child creation. +// The toolbox of a subagent has none of them, so these are unknown tools there. type Toolbox struct { log ToolLog chat ToolChat @@ -62,6 +63,9 @@ type Toolbox struct { whatsapp *WhatsApp schedules *Schedules workdir string + agentName string + messages *DaemonMessages + children *DaemonChildren } // Call logs the call of the tool name with its JSON arguments, makes the log @@ -156,6 +160,16 @@ func (b *Toolbox) run(ctx context.Context, name, arguments string) string { case err == nil: output, err = b.runs.Spawn(ctx, args.Tasks, args.Effort) } + case "agent_create": + var args ToolAgentCreateArguments + + err = toolDecodeArguments(arguments, &args) + switch { + case b.children == nil: + err = fmt.Errorf("unknown tool %q", name) + case err == nil: + output, err = b.children.Create(args.Name, args.Responsibility) + } case "tell": var args ToolTellArguments @@ -163,6 +177,9 @@ func (b *Toolbox) run(ctx context.Context, name, arguments string) string { switch { case b.runs == nil: err = fmt.Errorf("unknown tool %q", name) + case err == nil && b.messages != nil && b.messages.Child(b.agentName, args.Name) != nil: + err = b.messages.Tell(b.agentName, args.Name, args.Message) + output = "Sent. " + args.Name + "'s reply will reach you as a message." case err == nil: output, err = b.runs.Tell(ctx, args.Name, args.Message) } @@ -225,8 +242,15 @@ func (b *Toolbox) run(ctx context.Context, name, arguments string) string { return output } -// zoomAgent returns the whole log of the subagent name. +// zoomAgent returns a child's view or the whole log of a subagent. func (b *Toolbox) zoomAgent(ctx context.Context, name string) (string, error) { + if b.messages != nil { + child := b.messages.Child(b.agentName, name) + if child != nil { + return strings.Join(child.memory.ViewLines(nil), ""), nil + } + } + subagent, err := b.subagents.Get(ctx, name) if err != nil { return "", err @@ -410,8 +434,9 @@ func (b *Toolbox) path(path string) string { return filepath.Join(b.workdir, path) } -// 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. +// ToolZoomArguments are the arguments of zoom. A Name that is not nil names the +// child whose view or the subagent whose whole log zoom gives. ID and N are +// then not used. type ToolZoomArguments struct { ID int `json:"id"` N int `json:"n"` @@ -453,6 +478,12 @@ type ToolSpawnArguments struct { Effort *string `json:"effort"` } +// ToolAgentCreateArguments are the arguments of agent_create. +type ToolAgentCreateArguments struct { + Name string `json:"name"` + Responsibility string `json:"responsibility"` +} + // ToolTellArguments are the arguments of tell. type ToolTellArguments struct { Name string `json:"name"` diff --git a/turn.go b/turn.go index e559950..e0bd489 100644 --- a/turn.go +++ b/turn.go @@ -59,6 +59,7 @@ func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, whatsapp *W whatsapp: whatsapp, schedules: schedules, workdir: agent.config.Workdir, + agentName: agent.config.Name, } return l, nil @@ -92,18 +93,19 @@ type Event struct { // active belongs to the running turn. The mutex protects pending, cancel, and // failure. type TurnLoop struct { - agent *Agent - memory *Memory - client ChainClient - tools *Toolbox - runs *SubagentRuns - layout *ViewLayout - clock func() time.Time - logger *slog.Logger - notify func(Event) - wake chan struct{} - messages *DaemonMessages - active []Message + agent *Agent + memory *Memory + client ChainClient + tools *Toolbox + runs *SubagentRuns + layout *ViewLayout + clock func() time.Time + logger *slog.Logger + notify func(Event) + wake chan struct{} + messages *DaemonMessages + openChild func(context.Context, *Agent) (*DaemonAgent, error) + active []Message mutex sync.Mutex pending []Message @@ -447,9 +449,14 @@ func (l *TurnLoop) take() []Message { } // state returns the per-turn state: the end of the view, and the date and time -// of the turn. +// of the turn, then the names and responsibilities of its children. func (l *TurnLoop) state() string { - return "\n\nNow: " + l.clock().Format("Monday 2006-01-02 15:04 -07:00") + state := "\n\nNow: " + l.clock().Format("Monday 2006-01-02 15:04 -07:00") + if l.messages != nil { + state += l.messages.ChildState(l.agent.config.Name) + } + + return state } // turnError returns the text of the error that ended a turn.