]> git.giacomolli.uk - onechat/commitdiff
Manage source routes with root tools
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Tue, 10 Nov 2026 07:06:03 +0000 (04:06 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Tue, 10 Nov 2026 07:06:03 +0000 (04:06 -0300)
The root keeps a private source-to-child map and manages it through
route, unroute, and routes. Only direct children can receive a route,
and only WhatsApp chat sources are supported. The prompt requires user
agreement, and WhatsApp status lists the configured routes. Route logs
omit source IDs because they can contain phone numbers.

README.rst
agent.go
daemon_message.go
daemon_message_helper_test.go [new file with mode: 0644]
daemon_message_test.go
prompt.go
prompt_test.go
routes.go [new file with mode: 0644]
routes_test.go [new file with mode: 0644]
tools.go
whatsapp.go

index 556ab46e51bb0e2957c6eb230324cb61ed97c933..36db09c371132db8ddb1455b901533b5935521dc 100644 (file)
@@ -155,9 +155,9 @@ Each child has its own memory, model client, usage file, subagents,
 schedules, and turn loop. Its prompt limits work to its area, asks for
 only reports that need the parent or the user, and permits an empty
 reply. Children have no WhatsApp tools and draft replies instead of
 schedules, and turn loop. Its prompt limits work to its area, asks for
 only reports that need the parent or the user, and permits an empty
 reply. Children have no WhatsApp tools and draft replies instead of
-sending them. Clients and WhatsApp still address only the root. A
-child's saved schedules and subagents can resume at startup. The ready
-log line lists the child names.
+sending them. Clients address only the root. A child's saved schedules
+and subagents can resume at startup. The ready log line lists the child
+names.
 
 A message from a parent to its own child enters the child's log as
 ``user`` and wakes it. A child's final reply reaches the parent as
 
 A message from a parent to its own child enters the child's log as
 ``user`` and wakes it. A child's final reply reaches the parent as
@@ -443,7 +443,8 @@ device. The terminal client and Telegram take three commands:
 
 ``/whatsapp status``
    Shows the link: not linked, linked and connected, or linked and
 
 ``/whatsapp status``
    Shows the link: not linked, linked and connected, or linked and
-   disconnected, with the times of the connection.
+   disconnected, with the times of the connection and the WhatsApp
+   routes.
 
 ``/whatsapp unlink``
    Ends the link and deletes the session. When WhatsApp is disconnected,
 
 ``/whatsapp unlink``
    Ends the link and deletes the session. When WhatsApp is disconnected,
@@ -518,6 +519,13 @@ agent has the WhatsApp tools, not its subagents.
 Each batch of a group goes to the chat, also with no turn, and the
 memory tree summarizes it with model calls, which use the quota.
 
 Each batch of a group goes to the chat, also with no turn, and the
 memory tree summarizes it with model calls, which use the quota.
 
+The root manages source routes with ``route(source, agent)``,
+``unroute(source)``, and ``routes()``, only after the user agrees. A
+source is ``whatsapp:CHAT-ID``, with the ID from ``whatsapp_find`` or a
+``[WhatsApp]`` message, and the agent must be its child. The root keeps
+the map in ``agent/routes.json`` with mode 0600. Children and subagents
+have no route tools. There is no command to change routes.
+
 When the session holds a linked device, the daemon connects at its
 start. If the first connection fails, it tries again after 2 seconds,
 then doubles the wait up to 5 minutes. The inbox and commands stay
 When the session holds a linked device, the daemon connects at its
 start. If the first connection fails, it tries again after 2 seconds,
 then doubles the wait up to 5 minutes. The inbox and commands stay
index 9a85426382c900f69e9b6d1aaf4493d2fe9d71d3..dbcabd24321ffc33ed4607a00159239d598786b9 100644 (file)
--- a/agent.go
+++ b/agent.go
@@ -107,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 neither WhatsApp tools nor child creation.
+// child has no WhatsApp, child creation, or route tools.
 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_") || tool.Name == "agent_create"
+                       return strings.HasPrefix(tool.Name, "whatsapp_") || tool.Name == "agent_create" || tool.Name == "route" || tool.Name == "unroute" || tool.Name == "routes"
                })
        }
 
                })
        }
 
index 8415e49d4b7110d596eaed1013e79e6f29ce09b7..40a99a91f3bf76d8b5c5de314a18cb0d00cfeaef 100644 (file)
@@ -16,7 +16,13 @@ import (
 // messages before the loops start. A queue entry stays until its recipient
 // finishes the turn that takes it, so a stop can interrupt delivery or a turn.
 func openDaemonMessages(loops []*TurnLoop) (*DaemonMessages, error) {
 // messages before the loops start. A queue entry stays until its recipient
 // finishes the turn that takes it, so a stop can interrupt delivery or a turn.
 func openDaemonMessages(loops []*TurnLoop) (*DaemonMessages, error) {
+       routes, err := openRoutes(filepath.Join(loops[0].agent.dir, "routes.json"), loops[0].logger)
+       if err != nil {
+               return nil, fmt.Errorf("open agent routes: %w", err)
+       }
+
        m := &DaemonMessages{
        m := &DaemonMessages{
+               routes:  routes,
                path:    filepath.Join(loops[0].agent.dir, "messages.json"),
                ordered: append([]*TurnLoop(nil), loops...),
                loops:   make(map[string]*TurnLoop),
                path:    filepath.Join(loops[0].agent.dir, "messages.json"),
                ordered: append([]*TurnLoop(nil), loops...),
                loops:   make(map[string]*TurnLoop),
@@ -27,6 +33,13 @@ func openDaemonMessages(loops []*TurnLoop) (*DaemonMessages, error) {
                loop.tools.messages = m
                if loop.agent.config.Parent == "" {
                        loop.logger = loop.logger.With("name", loop.agent.config.Name)
                loop.tools.messages = m
                if loop.agent.config.Parent == "" {
                        loop.logger = loop.logger.With("name", loop.agent.config.Name)
+                       loop.tools.routes = routes
+                       if loop.tools.whatsapp != nil {
+                               loop.tools.whatsapp.mutex.Lock()
+
+                               loop.tools.whatsapp.routes = routes
+                               loop.tools.whatsapp.mutex.Unlock()
+                       }
                }
        }
 
                }
        }
 
@@ -89,6 +102,7 @@ type DaemonMessages struct {
        ordered []*TurnLoop
        state   DaemonMessageFile
        failure error
        ordered []*TurnLoop
        state   DaemonMessageFile
        failure error
+       routes  *Routes
 }
 
 // deliver records one queued message and wakes its recipient. The caller holds
 }
 
 // deliver records one queued message and wakes its recipient. The caller holds
diff --git a/daemon_message_helper_test.go b/daemon_message_helper_test.go
new file mode 100644 (file)
index 0000000..2122a93
--- /dev/null
@@ -0,0 +1,104 @@
+package main
+
+import (
+       "context"
+       "encoding/json"
+       "errors"
+       "os"
+       "path/filepath"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+// DaemonMessageTest owns test agents and the loops selected by Start.
+type DaemonMessageTest struct {
+       agents   DaemonAgents
+       loops    []*TurnLoop
+       events   []*TurnEventRecorder
+       messages *DaemonMessages
+       logs     *SyncBuffer
+       cancel   context.CancelFunc
+       done     []chan error
+}
+
+func (r *DaemonMessageTest) Start(t *testing.T, indices ...int) {
+       t.Helper()
+
+       ctx, cancel := context.WithCancel(t.Context())
+       r.cancel = cancel
+       for _, index := range indices {
+               done := make(chan error, 1)
+               r.done = append(r.done, done)
+               go func() {
+                       done <- r.loops[index].Run(ctx)
+               }()
+       }
+}
+
+func (r *DaemonMessageTest) Queue(t *testing.T) DaemonMessageFile {
+       t.Helper()
+
+       data, err := os.ReadFile(r.messages.path)
+       require.NoError(t, err)
+
+       var file DaemonMessageFile
+
+       require.NoError(t, json.Unmarshal(data, &file))
+
+       return file
+}
+
+func (r *DaemonMessageTest) Close(t *testing.T) {
+       t.Helper()
+
+       if r.cancel != nil {
+               r.cancel()
+
+               for _, done := range r.done {
+                       require.ErrorIs(t, <-done, context.Canceled)
+               }
+
+               r.cancel = nil
+               r.done = nil
+       }
+
+       require.NoError(t, r.agents.Close())
+
+       r.agents = nil
+}
+
+func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMessageTest {
+       t.Helper()
+
+       _, err := os.Stat(filepath.Join(dir, daemonAgentDirName, "children"))
+       if errors.Is(err, os.ErrNotExist) {
+               daemonChildConfig(t, dir, "Garden")
+               daemonChildConfig(t, dir, "Library")
+       } else {
+               require.NoError(t, err)
+       }
+
+       test := &DaemonMessageTest{
+               logs: &SyncBuffer{},
+       }
+       test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs))
+       require.NoError(t, err)
+       t.Cleanup(func() {
+               test.Close(t)
+       })
+
+       for _, agent := range test.agents {
+               events := &TurnEventRecorder{}
+               loop, err := agent.Loop(nil, events.Add)
+               require.NoError(t, err)
+
+               test.events = append(test.events, events)
+               test.loops = append(test.loops, loop)
+       }
+
+       test.messages, err = openDaemonMessages(test.loops)
+       require.NoError(t, err)
+
+       return test
+}
index 060faf5e0a7d4310e043e40afe3208acd3f40038..5574172468c0665498bafdfacca9f74b835be27f 100644 (file)
@@ -2,7 +2,6 @@ package main
 
 import (
        "context"
 
 import (
        "context"
-       "encoding/json"
        "errors"
        "net/http"
        "os"
        "errors"
        "net/http"
        "os"
@@ -558,95 +557,3 @@ func TestChildScheduleWakesOnlyItsOwner(t *testing.T) {
        require.Contains(t, test.events[1].LastEvent("work").Text, "Check the plants.")
        require.Equal(t, scheduleSource, test.events[1].LastEvent("work").Source)
 }
        require.Contains(t, test.events[1].LastEvent("work").Text, "Check the plants.")
        require.Equal(t, scheduleSource, test.events[1].LastEvent("work").Source)
 }
-
-// DaemonMessageTest owns test agents and the loops selected by Start.
-type DaemonMessageTest struct {
-       agents   DaemonAgents
-       loops    []*TurnLoop
-       events   []*TurnEventRecorder
-       messages *DaemonMessages
-       logs     *SyncBuffer
-       cancel   context.CancelFunc
-       done     []chan error
-}
-
-func (r *DaemonMessageTest) Start(t *testing.T, indices ...int) {
-       t.Helper()
-
-       ctx, cancel := context.WithCancel(t.Context())
-       r.cancel = cancel
-       for _, index := range indices {
-               done := make(chan error, 1)
-               r.done = append(r.done, done)
-               go func() {
-                       done <- r.loops[index].Run(ctx)
-               }()
-       }
-}
-
-func (r *DaemonMessageTest) Queue(t *testing.T) DaemonMessageFile {
-       t.Helper()
-
-       data, err := os.ReadFile(r.messages.path)
-       require.NoError(t, err)
-
-       var file DaemonMessageFile
-
-       require.NoError(t, json.Unmarshal(data, &file))
-
-       return file
-}
-
-func (r *DaemonMessageTest) Close(t *testing.T) {
-       t.Helper()
-
-       if r.cancel != nil {
-               r.cancel()
-
-               for _, done := range r.done {
-                       require.ErrorIs(t, <-done, context.Canceled)
-               }
-
-               r.cancel = nil
-               r.done = nil
-       }
-
-       require.NoError(t, r.agents.Close())
-
-       r.agents = nil
-}
-
-func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMessageTest {
-       t.Helper()
-
-       _, err := os.Stat(filepath.Join(dir, daemonAgentDirName, "children"))
-       if errors.Is(err, os.ErrNotExist) {
-               daemonChildConfig(t, dir, "Garden")
-               daemonChildConfig(t, dir, "Library")
-       } else {
-               require.NoError(t, err)
-       }
-
-       test := &DaemonMessageTest{
-               logs: &SyncBuffer{},
-       }
-       test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs))
-       require.NoError(t, err)
-       t.Cleanup(func() {
-               test.Close(t)
-       })
-
-       for _, agent := range test.agents {
-               events := &TurnEventRecorder{}
-               loop, err := agent.Loop(nil, events.Add)
-               require.NoError(t, err)
-
-               test.events = append(test.events, events)
-               test.loops = append(test.loops, loop)
-       }
-
-       test.messages, err = openDaemonMessages(test.loops)
-       require.NoError(t, err)
-
-       return test
-}
index 6bc737da08e1c18c0d027d4b72df3bd1666bdd62..36fda3288de8778c156fc51f35e79122b1015725 100644 (file)
--- a/prompt.go
+++ b/prompt.go
@@ -149,7 +149,9 @@ 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
 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.`
+agreement.
+Propose routing a source that talks only about one area to its child.
+Set or remove a route only after the user agrees.`
 
 // 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
 
 // 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
@@ -230,8 +232,8 @@ func promptSubagent(name, instructions string) string {
 
 // promptTools returns the function tools of every call of the agent: the tools
 // of promptSubagentTools, then spawn, agent_create, tell, the WhatsApp tools,
 
 // promptTools returns the function tools of every call of the agent: the tools
 // 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.
+// the route 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",
@@ -286,6 +288,29 @@ func promptTools() []ModelTool {
                        "text": promptStringSchema("The text of the message."),
                }),
                Strict: true,
                        "text": promptStringSchema("The text of the message."),
                }),
                Strict: true,
+       }, ModelTool{
+               Type:        "function",
+               Name:        "route",
+               Description: "Route a source to your child, only after the user agrees. Use whatsapp:CHAT-ID with the ID from whatsapp_find or a [WhatsApp] message.",
+               Parameters: promptObjectSchema(map[string]any{
+                       "source": promptStringSchema("The source key, whatsapp:CHAT-ID."),
+                       "agent":  promptStringSchema("The name of your child."),
+               }),
+               Strict: true,
+       }, ModelTool{
+               Type:        "function",
+               Name:        "unroute",
+               Description: "Remove a source route, only after the user agrees. New inputs go to you.",
+               Parameters: promptObjectSchema(map[string]any{
+                       "source": promptStringSchema("The source key, whatsapp:CHAT-ID."),
+               }),
+               Strict: true,
+       }, ModelTool{
+               Type:        "function",
+               Name:        "routes",
+               Description: "List source routes to your children.",
+               Parameters:  promptObjectSchema(map[string]any{}),
+               Strict:      true,
        }, ModelTool{
                Type:        "function",
                Name:        "schedule_set",
        }, ModelTool{
                Type:        "function",
                Name:        "schedule_set",
index 0aa9adbd205e5213c46c81e2d54e048b5b5a7b79..c9cadc2167022026836b29e6259992114c22afab 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", "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", "spawn", "agent_create", "tell", "whatsapp_find", "whatsapp_send", "route", "unroute", "routes", "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 {
@@ -156,6 +156,7 @@ func TestRootPromptDefinesChildRoutingAndConsent(t *testing.T) {
        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, `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, "Set or remove a route only after the user agrees.")
        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."))
 
        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."))
 
diff --git a/routes.go b/routes.go
new file mode 100644 (file)
index 0000000..7dce691
--- /dev/null
+++ b/routes.go
@@ -0,0 +1,156 @@
+package main
+
+import (
+       "encoding/json"
+       "errors"
+       "fmt"
+       "log/slog"
+       "os"
+       "slices"
+       "strings"
+       "sync"
+
+       "go.mau.fi/whatsmeow/types"
+)
+
+// openRoutes reads the source-to-child map. A missing file has no routes.
+func openRoutes(path string, logger *slog.Logger) (*Routes, error) {
+       r := &Routes{
+               path:    path,
+               logger:  logger,
+               targets: make(map[string]string),
+       }
+       data, err := os.ReadFile(path)
+       if errors.Is(err, os.ErrNotExist) {
+               return r, nil
+       }
+
+       if err != nil {
+               return nil, fmt.Errorf("read routes: %w", err)
+       }
+
+       err = json.Unmarshal(data, &r.targets)
+       if err != nil {
+               return nil, fmt.Errorf("decode routes: %w", err)
+       }
+
+       for source, target := range r.targets {
+               err = routeSource(source)
+               if err != nil {
+                       return nil, fmt.Errorf("validate route: %w", err)
+               }
+
+               if target == "" {
+                       return nil, errors.New("route has an empty agent name")
+               }
+       }
+
+       if r.targets == nil {
+               r.targets = make(map[string]string)
+       }
+
+       return r, nil
+}
+
+// Routes owns the root's source map. Writes replace the private file before
+// changing the map in memory. The mutex protects reads and writes from tools
+// and status commands. Logs omit source IDs, which can hold phone numbers.
+type Routes struct {
+       mutex   sync.Mutex
+       path    string
+       logger  *slog.Logger
+       targets map[string]string
+}
+
+// Set maps source to a child name, or removes it when target is empty. The
+// caller checks that a nonempty target is a child of the root.
+func (r *Routes) Set(source, target string) error {
+       err := routeSource(source)
+       if err != nil {
+               return fmt.Errorf("validate source: %w", err)
+       }
+
+       r.mutex.Lock()
+       defer r.mutex.Unlock()
+
+       targets := make(map[string]string, len(r.targets)+1)
+       for key, value := range r.targets {
+               targets[key] = value
+       }
+
+       if target == "" {
+               delete(targets, source)
+       } else {
+               targets[source] = target
+       }
+
+       data, err := json.Marshal(targets)
+       if err != nil {
+               return fmt.Errorf("encode routes: %w", err)
+       }
+
+       err = writeFileAtomic(r.path, append(data, '\n'))
+       if err != nil {
+               return fmt.Errorf("save routes: %w", err)
+       }
+
+       r.targets = targets
+       if target == "" {
+               r.logger.Info("route removed", "kind", whatsappSource)
+       } else {
+               r.logger.Info("route set", "kind", whatsappSource, "name", target)
+       }
+
+       return nil
+}
+
+// Target returns the configured name, or an empty string for an unrouted
+// source.
+func (r *Routes) Target(source string) string {
+       r.mutex.Lock()
+       defer r.mutex.Unlock()
+
+       return r.targets[source]
+}
+
+// List returns the routes in source order, or a short empty result.
+func (r *Routes) List() string {
+       r.mutex.Lock()
+       defer r.mutex.Unlock()
+
+       var sources []string
+
+       for source := range r.targets {
+               sources = append(sources, source)
+       }
+
+       slices.Sort(sources)
+
+       var lines []string
+
+       for _, source := range sources {
+               lines = append(lines, source+" -> "+r.targets[source])
+       }
+
+       if len(lines) == 0 {
+               return "No routes."
+       }
+
+       return strings.Join(lines, "\n")
+}
+
+// routeSource accepts the supported source kind and a canonical private or
+// group chat ID. The key keeps its kind so later sources can use the same map.
+func routeSource(source string) error {
+       kind, id, _ := strings.Cut(source, ":")
+       if kind != whatsappSource {
+               return errors.New("unknown source kind: use whatsapp:CHAT-ID")
+       }
+
+       jid, err := types.ParseJID(id)
+       if err != nil || jid.User == "" || jid.String() != id || jid != jid.ToNonAD() || (jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer && jid.Server != types.GroupServer) || strings.ContainsAny(id, " \t\r\n") {
+               return errors.New("invalid WhatsApp source: use whatsapp:CHAT-ID from whatsapp_find or a [WhatsApp] message")
+       }
+
+       return nil
+}
diff --git a/routes_test.go b/routes_test.go
new file mode 100644 (file)
index 0000000..dda77ea
--- /dev/null
@@ -0,0 +1,156 @@
+package main
+
+import (
+       "os"
+       "path/filepath"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestRootRouteToolsPersistAndRemoveRoutes(t *testing.T) {
+       dir := t.TempDir()
+       server := newModelFake(t)
+       test := newDaemonMessageTest(t, dir, server)
+       root := test.loops[0]
+       source := "whatsapp:" + whatsappTestFamily.String()
+       arguments := `{"source":"` + source + `","agent":"Garden"}`
+       require.Equal(t, "Route set.", root.tools.run(t.Context(), "route", arguments))
+       require.Equal(t, source+" -> Garden", root.tools.run(t.Context(), "routes", `{}`))
+
+       info, err := os.Stat(filepath.Join(root.agent.dir, "routes.json"))
+       require.NoError(t, err)
+       require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+       require.Contains(t, test.logs.String(), "route set kind=whatsapp name=Garden")
+       require.NotContains(t, test.logs.String(), whatsappTestFamily.String())
+       test.Close(t)
+
+       restored := newDaemonMessageTest(t, dir, server)
+       require.Equal(t, source+" -> Garden", restored.loops[0].tools.run(t.Context(), "routes", `{}`))
+       require.Equal(t, "Route removed.", restored.loops[0].tools.run(t.Context(), "unroute", `{"source":"`+source+`"}`))
+       require.Equal(t, "No routes.", restored.loops[0].tools.run(t.Context(), "routes", `{}`))
+       require.Contains(t, restored.logs.String(), "route removed kind=whatsapp")
+       restored.Close(t)
+
+       empty := newDaemonMessageTest(t, dir, server)
+       require.Equal(t, "No routes.", empty.loops[0].tools.run(t.Context(), "routes", `{}`))
+       require.Zero(t, server.Calls())
+}
+
+func TestRoutesRefuseUnknownSourcesAndNonchildren(t *testing.T) {
+       test := newDaemonMessageTest(t, t.TempDir(), newModelFake(t))
+       testCases := []struct {
+               name   string
+               source string
+               agent  string
+               cause  string
+       }{
+               {
+                       name:   "unknown kind",
+                       source: "mail:inbox",
+                       agent:  "Garden",
+                       cause:  "unknown source kind",
+               },
+               {
+                       name:   "missing kind",
+                       source: whatsappTestFamily.String(),
+                       agent:  "Garden",
+                       cause:  "unknown source kind",
+               },
+               {
+                       name:   "empty chat",
+                       source: "whatsapp:",
+                       agent:  "Garden",
+                       cause:  "invalid WhatsApp source",
+               },
+               {
+                       name:   "unknown chat kind",
+                       source: "whatsapp:example@unknown",
+                       agent:  "Garden",
+                       cause:  "invalid WhatsApp source",
+               },
+               {
+                       name:   "root is not a child",
+                       source: "whatsapp:" + whatsappTestFamily.String(),
+                       agent:  "OneChat",
+                       cause:  "must be a child",
+               },
+               {
+                       name:   "unknown child",
+                       source: "whatsapp:" + whatsappTestFamily.String(),
+                       agent:  "Other",
+                       cause:  "must be a child",
+               },
+               {
+                       name:   "empty child",
+                       source: "whatsapp:" + whatsappTestFamily.String(),
+                       agent:  "",
+                       cause:  "must be a child",
+               },
+       }
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       arguments := `{"source":"` + tc.source + `","agent":"` + tc.agent + `"}`
+                       require.Contains(t, test.loops[0].tools.run(t.Context(), "route", arguments), tc.cause)
+               })
+       }
+
+       require.Contains(t, test.loops[0].tools.run(t.Context(), "unroute", `{"source":"mail:inbox"}`), "unknown source kind")
+       require.Equal(t, "No routes.", test.messages.routes.List())
+}
+
+func TestRouteToolsBelongOnlyToRoot(t *testing.T) {
+       test := newDaemonMessageTest(t, t.TempDir(), newModelFake(t))
+       for _, name := range []string{"route", "unroute", "routes"} {
+               require.Contains(t, promptToolNames(test.agents[0].agent.Tools()), name)
+               require.NotContains(t, promptToolNames(test.agents[1].agent.Tools()), name)
+               require.NotContains(t, promptToolNames(promptSubagentTools()), name)
+               require.Contains(t, test.loops[1].tools.run(t.Context(), name, `{}`), "unknown tool")
+       }
+
+       require.NotContains(t, promptChild("Garden", "OneChat", "Keep plans.", "/CHILD/instructions.md", ""), "Propose routing a source")
+}
+
+func TestWhatsAppStatusListsRoutes(t *testing.T) {
+       whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
+       test := newDaemonMessageTest(t, t.TempDir(), newModelFake(t))
+       whatsapp.routes = test.messages.routes
+       require.NoError(t, test.messages.routes.Set("whatsapp:"+whatsappTestFamily.String(), "Garden"))
+
+       var answer string
+
+       whatsapp.Command("status", func(text string) {
+               answer = text
+       })
+       require.Contains(t, answer, "WhatsApp is not linked.")
+       require.Contains(t, answer, "WhatsApp routes:\nwhatsapp:"+whatsappTestFamily.String()+" -> Garden")
+}
+
+func TestInvalidRouteFileStopsStartup(t *testing.T) {
+       testCases := []struct {
+               name string
+               text string
+       }{
+               {
+                       name: "invalid JSON",
+                       text: "{",
+               },
+               {
+                       name: "unknown source",
+                       text: `{"mail:inbox":"Garden"}`,
+               },
+               {
+                       name: "empty destination",
+                       text: `{"whatsapp:example@lid":""}`,
+               },
+       }
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       path := filepath.Join(t.TempDir(), "routes.json")
+                       require.NoError(t, os.WriteFile(path, []byte(tc.text), 0o600))
+
+                       _, err := openRoutes(path, discardLog())
+                       require.Error(t, err)
+               })
+       }
+}
index b4fb075b40a5cc7b6a6086417e1bce375a4bfc79..a75514f984964224f0e1075d06281cda3be79cb0 100644 (file)
--- a/tools.go
+++ b/tools.go
@@ -53,8 +53,9 @@ type ToolChat interface {
 // 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
 // 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.
+// the root has child creation and source routes. A child has neither of them
+// nor a WhatsApp link. 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
@@ -66,6 +67,7 @@ type Toolbox struct {
        agentName string
        messages  *DaemonMessages
        children  *DaemonChildren
        agentName string
        messages  *DaemonMessages
        children  *DaemonChildren
+       routes    *Routes
 }
 
 // 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
@@ -170,6 +172,36 @@ func (b *Toolbox) run(ctx context.Context, name, arguments string) string {
                case err == nil:
                        output, err = b.children.Create(args.Name, args.Responsibility)
                }
                case err == nil:
                        output, err = b.children.Create(args.Name, args.Responsibility)
                }
+       case "route", "unroute", "routes":
+               var args ToolRouteArguments
+
+               switch name {
+               case "routes":
+                       err = toolDecodeArguments(arguments, &struct{}{})
+               case "unroute":
+                       var source ToolRouteSourceArguments
+
+                       err = toolDecodeArguments(arguments, &source)
+                       args.Source = source.Source
+               default:
+                       err = toolDecodeArguments(arguments, &args)
+               }
+
+               switch {
+               case b.routes == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err != nil:
+               case name == "routes":
+                       output = b.routes.List()
+               case name == "route" && (b.messages == nil || b.messages.Child(b.agentName, args.Agent) == nil):
+                       err = errors.New("the agent must be a child of the root")
+               default:
+                       err = b.routes.Set(args.Source, args.Agent)
+                       output = "Route removed."
+                       if name == "route" {
+                               output = "Route set."
+                       }
+               }
        case "tell":
                var args ToolTellArguments
 
        case "tell":
                var args ToolTellArguments
 
@@ -484,6 +516,17 @@ type ToolAgentCreateArguments struct {
        Responsibility string `json:"responsibility"`
 }
 
        Responsibility string `json:"responsibility"`
 }
 
+// ToolRouteArguments are the arguments of route.
+type ToolRouteArguments struct {
+       Source string `json:"source"`
+       Agent  string `json:"agent"`
+}
+
+// ToolRouteSourceArguments are the arguments of unroute.
+type ToolRouteSourceArguments struct {
+       Source string `json:"source"`
+}
+
 // 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"`
index 0c68a44ace8a0184b89789e473537d75ec876199..851cfd1c348053a8dba203afc24a20e7e4a55ec3 100644 (file)
@@ -190,6 +190,7 @@ type WhatsApp struct {
        state       *WhatsAppState
        groups      *WhatsAppGroups
        inbox       *WhatsAppInbox
        state       *WhatsAppState
        groups      *WhatsAppGroups
        inbox       *WhatsAppInbox
+       routes      *Routes
 
        sessionMutex   sync.Mutex
        mutex          sync.Mutex
 
        sessionMutex   sync.Mutex
        mutex          sync.Mutex
@@ -358,23 +359,31 @@ func (w *WhatsApp) Command(args string, answer func(text string)) {
 }
 
 // Status returns the state of the link: not linked, linked and connected, or
 }
 
 // Status returns the state of the link: not linked, linked and connected, or
-// linked and disconnected, with the times of the connection. It never holds the
-// phone number.
+// linked and disconnected, with the times of the connection and the routes.
+// Route source IDs can hold the phone numbers of other chats.
 func (w *WhatsApp) Status() string {
        w.mutex.Lock()
        defer w.mutex.Unlock()
 
 func (w *WhatsApp) Status() string {
        w.mutex.Lock()
        defer w.mutex.Unlock()
 
+       routes := ""
+       if w.routes != nil {
+               listed := w.routes.List()
+               if listed != "No routes." {
+                       routes = "\nWhatsApp routes:\n" + listed
+               }
+       }
+
        switch {
        case !w.linked:
        switch {
        case !w.linked:
-               return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it."
+               return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it." + routes
        case w.connected:
        case w.connected:
-               return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "."
+               return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
        case w.disconnectedAt.IsZero():
        case w.disconnectedAt.IsZero():
-               return "WhatsApp is linked and not connected yet."
+               return "WhatsApp is linked and not connected yet." + routes
        case w.connectedAt.IsZero():
        case w.connectedAt.IsZero():
-               return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "."
+               return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "." + routes
        default:
        default:
-               return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "."
+               return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
        }
 }
 
        }
 }