]> git.giacomolli.uk - onechat/commitdiff
Give the root tools to start and message children
authorAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 06:46:17 +0000 (03:46 -0300)
committerAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 06:46:17 +0000 (03:46 -0300)
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

13 files changed:
README.rst
agent.go
daemon.go
daemon_agent.go
daemon_children.go [new file with mode: 0644]
daemon_children_test.go [new file with mode: 0644]
daemon_message.go
prompt.go
prompt_helper_test.go [new file with mode: 0644]
prompt_test.go
subagent.go
tools.go
turn.go

index b413c2ba76388602dfa98ad5a33010334bdd3a63..556ab46e51bb0e2957c6eb230324cb61ed97c933 100644 (file)
@@ -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
 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
 
 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.
 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
 
 **************
  User service
index 274806be60c2f5d772f5d4d38de1693ef4f1bb76..9a85426382c900f69e9b6d1aaf4493d2fe9d71d3 100644 (file)
--- a/agent.go
+++ b/agent.go
@@ -12,6 +12,8 @@ import (
        "path/filepath"
        "slices"
        "strings"
        "path/filepath"
        "slices"
        "strings"
+
+       "golang.org/x/sys/unix"
 )
 
 const (
 )
 
 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
 }
 
 // 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 {
 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
 }
 
        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) {
 // 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
 }
 
        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) {
 // 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 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)
        id := make([]byte, agentIDBytes)
-       _, err = rand.Read(id)
+       _, err := rand.Read(id)
        if err != nil {
                return nil, fmt.Errorf("make agent ID: %w", err)
        }
        if err != nil {
                return nil, fmt.Errorf("make agent ID: %w", err)
        }
index 958d6f7bd2283fe8b2796c14ae345e7b5f14e604..2a93f1dfbdd699ec48eac3a300e7b4cc802a1109 100644 (file)
--- 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 {
        }
 
        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
 
 
        var workers sync.WaitGroup
 
@@ -449,7 +455,7 @@ func daemonServe(ctx context.Context, listener net.Listener, bot *TelegramBot, w
        hub.CloseAll()
        workers.Wait()
 
        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.
 }
 
 // daemonRunChild runs a child's turn loop and its schedules until either stops.
index 4520f35cb200ce49caf2233d6149881cdfa545ed..662291c637c14c208987e135316bf1f8e0141112 100644 (file)
@@ -73,6 +73,9 @@ func openDaemonAgent(ctx context.Context, agent *Agent, address string, auth Mod
                schedules: schedules,
                clock:     clock,
                logger:    logger,
                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
 }
 
        }, nil
 }
 
@@ -86,12 +89,20 @@ type DaemonAgent struct {
        schedules *Schedules
        clock     func() time.Time
        logger    *slog.Logger
        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) {
 }
 
 // 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.
 }
 
 // Close flushes and closes the subagents and the memory.
diff --git a/daemon_children.go b/daemon_children.go
new file mode 100644 (file)
index 0000000..ddb14c0
--- /dev/null
@@ -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 (file)
index 0000000..16f326f
--- /dev/null
@@ -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
+}
index db15db8073c4624a5c9bf57b3fdadaac46601f76..8415e49d4b7110d596eaed1013e79e6f29ce09b7 100644 (file)
@@ -1,6 +1,7 @@
 package main
 
 import (
 package main
 
 import (
+       "context"
        "encoding/json"
        "errors"
        "fmt"
        "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{
 // 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
        }
        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)
                }
        }
 
                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)
        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
        mutex   sync.Mutex
        path    string
        loops   map[string]*TurnLoop
+       ordered []*TurnLoop
        state   DaemonMessageFile
        failure error
 }
        state   DaemonMessageFile
        failure error
 }
@@ -125,6 +136,83 @@ func (m *DaemonMessages) save() error {
        return nil
 }
 
        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 {
 // 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 {
index 934491f3e642cf5b3842430009ab1de110f47f0f..6bc737da08e1c18c0d027d4b72df3bd1666bdd62 100644 (file)
--- 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
 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
 
 
 # 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.`
 
 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
 // 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)
 // 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)
 }
 
        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)
 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)
 }
 
        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
 }
 
 // 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",
 func promptTools() []ModelTool {
        return append(promptSubagentTools(), ModelTool{
                Type:        "function",
@@ -240,12 +250,21 @@ func promptTools() []ModelTool {
                        },
                }),
                Strict: true,
                        },
                }),
                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",
        }, 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{
                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,
                        "message": promptStringSchema("The message."),
                }),
                Strict: true,
diff --git a/prompt_helper_test.go b/prompt_helper_test.go
new file mode 100644 (file)
index 0000000..5114036
--- /dev/null
@@ -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
+}
index ba02b23b1acc0379e664e3715b2f6c1a13b627b4..0aa9adbd205e5213c46c81e2d54e048b5b5a7b79 100644 (file)
@@ -128,7 +128,7 @@ func TestProbePromptKeepsTheInstructionsPathStable(t *testing.T) {
 
 func TestAgentToolsAreStrictAndStable(t *testing.T) {
        tools := promptTools()
 
 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 {
        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")
 }
 }
index 07bfa452f6c8aef7bc18bf10c6bbc097668f5793..4cb638191eb1fab90544b016c575d074242187b0 100644 (file)
@@ -28,10 +28,11 @@ const (
 // subagents.
 func newSubagents(dir string, clock func() time.Time, draw func() string) *Subagents {
        return &Subagents{
 // 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
 
        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
 }
 
 // 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()
 
        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
                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)
 }
 
        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) {
 // 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) {
index 0811981250aadef77453a31f17d53248eb4a067e..b4fb075b40a5cc7b6a6086417e1bce375a4bfc79 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
 // 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
 type Toolbox struct {
        log       ToolLog
        chat      ToolChat
@@ -62,6 +63,9 @@ type Toolbox struct {
        whatsapp  *WhatsApp
        schedules *Schedules
        workdir   string
        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
 }
 
 // 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 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
 
        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)
                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)
                }
                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
 }
 
        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) {
 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
        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)
 }
 
        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"`
 type ToolZoomArguments struct {
        ID   int     `json:"id"`
        N    int     `json:"n"`
@@ -453,6 +478,12 @@ type ToolSpawnArguments struct {
        Effort *string  `json:"effort"`
 }
 
        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"`
 // ToolTellArguments are the arguments of tell.
 type ToolTellArguments struct {
        Name    string `json:"name"`
diff --git a/turn.go b/turn.go
index e559950675925ccf3fb51d470c7ce8a2af2bb803..e0bd489f323549a19f19706d4e68fd04c656c762 100644 (file)
--- 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,
                whatsapp:  whatsapp,
                schedules: schedules,
                workdir:   agent.config.Workdir,
+               agentName: agent.config.Name,
        }
 
        return l, nil
        }
 
        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 {
 // 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
 
        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
 }
 
 // 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 {
 func (l *TurnLoop) state() string {
-       return "</chat>\n\nNow: " + l.clock().Format("Monday 2006-01-02 15:04 -07:00")
+       state := "</chat>\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.
 }
 
 // turnError returns the text of the error that ended a turn.