From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 07:06:03 +0000 (-0300) Subject: Manage source routes with root tools X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/723f6e18adc6482ed913e1618c8a76e981720c2b?ds=sidebyside Manage source routes with root tools 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. --- diff --git a/README.rst b/README.rst index 556ab46..36db09c 100644 --- a/README.rst +++ b/README.rst @@ -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 -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 @@ -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 - 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, @@ -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. +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 diff --git a/agent.go b/agent.go index 9a85426..dbcabd2 100644 --- 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 -// 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 { - 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" }) } diff --git a/daemon_message.go b/daemon_message.go index 8415e49..40a99a9 100644 --- a/daemon_message.go +++ b/daemon_message.go @@ -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) { + 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{ + routes: routes, 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.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 + routes *Routes } // 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 index 0000000..2122a93 --- /dev/null +++ b/daemon_message_helper_test.go @@ -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 +} diff --git a/daemon_message_test.go b/daemon_message_test.go index 060faf5..5574172 100644 --- a/daemon_message_test.go +++ b/daemon_message_test.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "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) } - -// 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 -} diff --git a/prompt.go b/prompt.go index 6bc737d..36fda32 100644 --- 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 -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 @@ -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, -// 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", @@ -286,6 +288,29 @@ func promptTools() []ModelTool { "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", diff --git a/prompt_test.go b/prompt_test.go index 0aa9adb..c9cadc2 100644 --- a/prompt_test.go +++ b/prompt_test.go @@ -128,7 +128,7 @@ func TestProbePromptKeepsTheInstructionsPathStable(t *testing.T) { func TestAgentToolsAreStrictAndStable(t *testing.T) { tools := promptTools() - require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "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 { @@ -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, "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.")) diff --git a/routes.go b/routes.go new file mode 100644 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 index 0000000..dda77ea --- /dev/null +++ b/routes_test.go @@ -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) + }) + } +} diff --git a/tools.go b/tools.go index b4fb075..a75514f 100644 --- 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 -// 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 @@ -66,6 +67,7 @@ type Toolbox struct { agentName string messages *DaemonMessages children *DaemonChildren + routes *Routes } // 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 "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 @@ -484,6 +516,17 @@ type ToolAgentCreateArguments struct { 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"` diff --git a/whatsapp.go b/whatsapp.go index 0c68a44..851cfd1 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -190,6 +190,7 @@ type WhatsApp struct { state *WhatsAppState groups *WhatsAppGroups inbox *WhatsAppInbox + routes *Routes 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 -// 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() + routes := "" + if w.routes != nil { + listed := w.routes.List() + if listed != "No routes." { + routes = "\nWhatsApp routes:\n" + listed + } + } + 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: - 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(): - return "WhatsApp is linked and not connected yet." + return "WhatsApp is linked and not connected yet." + routes 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: - 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 } }