From e1e48ba388c28105fff3b70c8ca02304f5d57f53 Mon Sep 17 00:00:00 2001 From: "Anderson A. Giacomolli" Date: Sun, 8 Nov 2026 10:48:37 -0300 Subject: [PATCH] Send messages to subagents with tell The agent gets the tell tool: tell(name, message) logs the message as a user message of the subagent. A running subagent reads it between its tool calls, and when it comes after a final answer, the run goes on with one more request chain that reads the log again. The report of the run then holds the final answer of each chain. A finished subagent goes on from its log with a new run, with the view of now and the effort of the note at the start of its log, and its reply comes back as a new report. The log holds every message of a tell, so a told subagent needs no state outside its log, and a restart can resume it in the same way. Issue: b1ac698 --- README.rst | 8 +- prompt.go | 11 ++- prompt_test.go | 2 +- subagent.go | 8 ++ subagent_run.go | 189 ++++++++++++++++++++++++++++++++++--------- subagent_run_test.go | 106 ++++++++++++++++++++++++ tools.go | 20 ++++- 7 files changed, 301 insertions(+), 43 deletions(-) diff --git a/README.rst b/README.rst index 82a8e13..3819e25 100644 --- a/README.rst +++ b/README.rst @@ -95,9 +95,11 @@ the agent and its task, and it runs its tools as the agent does, in ``agent/subagents/NAME/work``. A task in a repository gives the path of the repository. The steps of a subagent go to its own log in ``agent/subagents/NAME``, not to the chat, and its final reply comes -back to the agent as one message that starts with ``[NAME]``. Each model -call of a subagent has the name in the ``agent`` field of -``agent/usage.jsonl``. +back to the agent as one message that starts with ``[NAME]``. The agent +can send a subagent more messages: a running subagent reads them between +its tool calls, and a finished one goes on from its log, and its reply +comes back in the same way. Each model call of a subagent has the name +in the ``agent`` field of ``agent/usage.jsonl``. ``agent/config.json`` holds the settings of the agent. The first start writes it with the defaults and a random ID. Do not change the ID: the diff --git a/prompt.go b/prompt.go index 8bd9691..ee86ebd 100644 --- a/prompt.go +++ b/prompt.go @@ -185,7 +185,7 @@ func promptSubagent(name, instructions string) string { } // promptTools returns the function tools of every call of the agent: the tools -// of promptSubagentTools, then spawn. A compaction sends them too and never +// of promptSubagentTools, then spawn and tell. 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{ @@ -205,6 +205,15 @@ func promptTools() []ModelTool { }, }), Strict: true, + }, ModelTool{ + Type: "function", + Name: "tell", + Description: `Send a message to a subagent, by name. One at work reads it between its tool calls, and its report answers it; a finished one goes on from its chat, and its reply reaches you as a message "[Name] reply".`, + Parameters: promptObjectSchema(map[string]any{ + "name": promptStringSchema("The name of the subagent."), + "message": promptStringSchema("The message."), + }), + Strict: true, }) } diff --git a/prompt_test.go b/prompt_test.go index d2f34fa..6adb0ba 100644 --- a/prompt_test.go +++ b/prompt_test.go @@ -77,7 +77,7 @@ func TestPromptWithoutInstructionsHasNoClause(t *testing.T) { func TestAgentToolsAreStrictAndStable(t *testing.T) { tools := promptTools() - require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn"}, promptToolNames(tools)) + require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "tell"}, promptToolNames(tools)) require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, promptToolNames(promptSubagentTools())) for _, tool := range tools { diff --git a/subagent.go b/subagent.go index 596c038..2e50d5d 100644 --- a/subagent.go +++ b/subagent.go @@ -169,6 +169,14 @@ func (a *Subagent) Flush(ctx context.Context) error { return a.syncer.Flush(ctx) } +// First returns the first message of the log. +func (a *Subagent) First() (Message, error) { + a.mutex.Lock() + defer a.mutex.Unlock() + + return a.log.Message(0) +} + // Show returns the whole log, each message as "i|kind: text", with a blank line // between messages. func (a *Subagent) Show() (string, error) { diff --git a/subagent_run.go b/subagent_run.go index 3ee98a0..b2e5e62 100644 --- a/subagent_run.go +++ b/subagent_run.go @@ -23,15 +23,18 @@ const ( var errSubagentClosed = errors.New("the daemon stops, so no subagent starts") // SubagentRuns runs the subagents of one agent in the background. Each run is -// a goroutine with its own Chain and its own context, which only Close ends. -// The first request of each chain holds the view of the agent at the spawn, -// then the state of the turn, then the whole log of the subagent. The report of -// the run goes to report: its final answer, or the error of a failed call. At -// the stop of the daemon the runs end with no report. +// a goroutine with its own context, which only Close ends. A run is one or more +// request chains. The first request of each chain holds the view of the agent +// at the start of the run, then the state of the turn, then the whole log of +// the subagent. A message of Tell that comes during a chain joins its next +// request after the tool results. One that comes after the final answer starts +// one more chain, which reads the log again. The report of the run goes to +// report: the final answer of each chain, or the error of a failed call. At the +// stop of the daemon the runs end with no report. // -// Spawn splits the view with the layout of the agent, which has no lock. Spawn -// is a tool, so it runs in the goroutine of the turns, as each split of a turn -// does. +// Spawn and Tell split the view with the layout of the agent, which has no +// lock. They are tools, so they run in the goroutine of the turns, as each +// split of a turn does. type SubagentRuns struct { agent *Agent memory *Memory @@ -102,6 +105,85 @@ func (r *SubagentRuns) Spawn(ctx context.Context, tasks []string, effort *string return "Started " + strings.Join(names, ", ") + ". Each one's report will reach you as a message when it finishes.", nil } +// Tell sends message to the subagent name. A running subagent reads it between +// its tool calls, and its report answers it. A finished subagent goes on from +// its log with a new run, with the view of now and the effort of its note. +func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, error) { + r.mutex.Lock() + run, found := r.running[name] + if found { + defer r.mutex.Unlock() + + told, err := run.subagent.Append("user", message) + if err != nil { + return "", err + } + + run.pending = append(run.pending, told) + + return "Sent. " + name + " reads it between its tool calls, and its report answers it.", nil + } + + closed := r.closed + r.mutex.Unlock() + + if closed { + return "", errSubagentClosed + } + + subagent, err := r.subagents.Get(ctx, name) + if err != nil { + return "", err + } + + effort, err := r.effort(subagent) + if err != nil { + return "", err + } + + err = r.memory.Settle(ctx) + if err != nil { + return "", fmt.Errorf("wait for the summaries: %w", err) + } + + view, err := r.layout.Split(r.memory.ViewLines(nil)) + if err != nil { + return "", err + } + + _, err = subagent.Append("user", message) + if err == nil { + err = subagent.Flush(ctx) + } + + if err != nil { + return "", err + } + + err = r.start(ctx, &SubagentRun{ + subagent: subagent, + view: view, + effort: effort, + }) + if err != nil { + return "", err + } + + return "Sent. " + name + "'s reply will reach you as a message.", nil +} + +// Close ends every run and waits for them. No subagent starts after Close. +func (r *SubagentRuns) Close() { + r.mutex.Lock() + r.closed = true + for _, run := range r.running { + run.cancel() + } + r.mutex.Unlock() + + r.workers.Wait() +} + // create makes a new subagent with its note, its task, and its work directory, // and starts its run. func (r *SubagentRuns) create(ctx context.Context, note, task string, view []string, effort string) (string, error) { @@ -134,16 +216,41 @@ func (r *SubagentRuns) create(ctx context.Context, note, task string, view []str }) } +// effort returns the effort of subagent from the note at the start of its log: +// the effort of the agent, or the effort that the note names. +func (r *SubagentRuns) effort(subagent *Subagent) (string, error) { + note, err := subagent.First() + if err != nil { + return "", err + } + + effort, own := strings.CutPrefix(note.Text, subagentNote+subagentNoteEffort) + switch { + case note.Kind != "note": + case own: + return effort, nil + case note.Text == subagentNote: + return r.agent.config.Effort, nil + } + + return "", fmt.Errorf("the log of %s does not start with a subagent note", subagent.name) +} + // start runs run in the background. Its context keeps the values of ctx, but // only Close ends it. func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error { ctx, run.cancel = context.WithCancel(context.WithoutCancel(ctx)) r.mutex.Lock() - if r.closed { + _, found := r.running[run.subagent.name] + if r.closed || found { r.mutex.Unlock() run.cancel() + if found { + return fmt.Errorf("%s runs already", run.subagent.name) + } + return errSubagentClosed } @@ -161,35 +268,48 @@ func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error { return nil } -// run runs the chain of run and reports its end, unless Close ended it. +// run runs the chains of run and reports its end, unless Close ended it. A +// chain follows while messages wait after the final answer of the chain before +// it, and no chain failed. func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) { defer r.workers.Done() defer run.cancel() - reply := r.chain(ctx, run) + var replies []string + for more := true; more; { + reply, err := r.chain(ctx, run) + if err != nil { + reply = "failed: " + err.Error() + } + + replies = append(replies, reply) - r.mutex.Lock() - delete(r.running, run.subagent.name) - r.mutex.Unlock() + r.mutex.Lock() + more = err == nil && len(run.pending) > 0 + run.pending = nil + if !more { + delete(r.running, run.subagent.name) + } + r.mutex.Unlock() + } if ctx.Err() != nil { return } - r.report(subagentReport(run.subagent.name, []string{reply}), false) + r.report(subagentReport(run.subagent.name, replies), false) } -// chain runs one request chain of run and returns its final answer, or the -// error of the chain after "failed: ". -func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) string { +// chain runs one request chain of run and returns its final answer. +func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) (string, error) { instructions, err := r.agent.SubagentPrompt() if err != nil { - return "failed: " + err.Error() + return "", err } log, err := run.subagent.Show() if err != nil { - return "failed: " + err.Error() + return "", err } input := make([]any, 0, len(run.view)+2) @@ -214,37 +334,34 @@ func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) string { Agent: run.subagent.name, }, take: func() []Message { - return nil + return r.take(run) }, } - reply, err := chain.Run(ctx, input) - if err != nil { - return "failed: " + err.Error() - } - - return reply + return chain.Run(ctx, input) } -// Close ends every run and waits for them. No subagent starts after Close. -func (r *SubagentRuns) Close() { +// take returns the messages of Tell that wait for a request of run, and +// removes them. +func (r *SubagentRuns) take(run *SubagentRun) []Message { r.mutex.Lock() - r.closed = true - for _, run := range r.running { - run.cancel() - } - r.mutex.Unlock() + defer r.mutex.Unlock() - r.workers.Wait() + messages := run.pending + run.pending = nil + + return messages } // SubagentRun is one run of a subagent: the view that its requests get, its -// effort, and the end of its context. +// effort, the end of its context, and the messages of Tell that wait for a +// request. The mutex of SubagentRuns protects the messages. type SubagentRun struct { subagent *Subagent view []string effort string cancel context.CancelFunc + pending []Message } // subagentReport returns the report of the subagent name with its replies: the diff --git a/subagent_run_test.go b/subagent_run_test.go index 3027fe6..fc13f60 100644 --- a/subagent_run_test.go +++ b/subagent_run_test.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -224,6 +225,111 @@ func TestSubagentHasItsOwnEffortAndNoSpawn(t *testing.T) { require.Len(t, entries, 1) } +func TestTellReachesARunningSubagentBetweenItsToolCalls(t *testing.T) { + server := newModelFake(t) + arrived := make(chan struct{}) + told := make(chan struct{}) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + switch { + case !subagentTestCall(call): + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5)) + case call.number == 0: + close(arrived) + <-told + modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"true"}`)}, 100, 0, 5)) + default: + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Used the second disk.")}, 100, 0, 5)) + } + } + + test := newTestTurnLoop(t, server) + runs := test.loop.runs + _, err := runs.Spawn(t.Context(), []string{"Copy the files."}, nil) + require.NoError(t, err) + + <-arrived + name := test.events.Last("subagent") + output, err := runs.Tell(t.Context(), name, "Use the second disk.") + require.NoError(t, err) + require.Equal(t, "Sent. "+name+" reads it between its tool calls, and its report answers it.", output) + close(told) + + test.events.WaitFor(t, "idle", 1) + require.Equal(t, "Use the second disk.", subagentTestText(subagentTestLastItem(server.Call(1)))) + require.Equal(t, "work: "+subagentReport(name, []string{"Used the second disk."}), test.Log(t)[0]) +} + +func TestTellAfterTheFinalAnswerStartsAnotherChain(t *testing.T) { + server := newModelFake(t) + arrived := make(chan struct{}) + told := make(chan struct{}) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + switch { + case !subagentTestCall(call): + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5)) + case call.number == 0: + close(arrived) + <-told + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("First.")}, 100, 0, 5)) + default: + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Second.")}, 100, 0, 5)) + } + } + + test := newTestTurnLoop(t, server) + runs := test.loop.runs + _, err := runs.Spawn(t.Context(), []string{"Count."}, nil) + require.NoError(t, err) + + <-arrived + name := test.events.Last("subagent") + _, err = runs.Tell(t.Context(), name, "Count again.") + require.NoError(t, err) + close(told) + + test.events.WaitFor(t, "idle", 1) + require.Equal(t, "0|note: subagent\n\n1|user: Count.\n\n2|user: Count again.\n\n3|reply: First.", subagentTestText(subagentTestLastItem(server.Call(1)))) + require.Equal(t, "work: "+subagentReport(name, []string{"First.", "Second."}), test.Log(t)[0]) +} + +func TestTellToAFinishedSubagentGoesOnFromItsLog(t *testing.T) { + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + text := "Noted." + if subagentTestCall(call) { + text = "Reply " + strconv.Itoa(call.number) + "." + } + + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5)) + } + + test := newTestTurnLoop(t, server) + runs := test.loop.runs + effort := "high" + _, err := runs.Spawn(t.Context(), []string{"Count."}, &effort) + require.NoError(t, err) + test.events.WaitFor(t, "idle", 1) + + name := test.events.Last("subagent") + output, err := runs.Tell(t.Context(), name, "And the hidden files?") + require.NoError(t, err) + require.Equal(t, "Sent. "+name+"'s reply will reach you as a message.", output) + test.events.WaitFor(t, "idle", 2) + + call := server.Call(2) + require.True(t, subagentTestCall(call)) + require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"]) + require.Equal(t, "0|note: subagent at effort high\n\n1|user: Count.\n\n2|reply: Reply 0.\n\n3|user: And the hidden files?", subagentTestText(subagentTestLastItem(call))) + + // The view of the new run holds the chat of now, with the first report. + texts := turnInputTexts(t, call) + require.Contains(t, strings.Join(texts[:len(texts)-2], ""), "work: ["+name+"]") + require.Equal(t, "work: "+subagentReport(name, []string{"Reply 2."}), test.Log(t)[2]) + + _, err = runs.Tell(t.Context(), "BlueFox", "Hello.") + require.EqualError(t, err, "no agent BlueFox") +} + func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) { server := newModelFake(t) arrived := make(chan struct{}) diff --git a/tools.go b/tools.go index 737618e..dd28ae0 100644 --- a/tools.go +++ b/tools.go @@ -50,8 +50,9 @@ type ToolChat interface { // 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. The -// toolbox of a subagent has none, so spawn is an unknown tool there. +// The toolbox of the agent has the runs of its subagents for spawn and tell. +// The toolbox of a subagent has none, so spawn and tell are unknown tools +// there. type Toolbox struct { log ToolLog chat ToolChat @@ -142,6 +143,15 @@ 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 "tell": + var args ToolTellArguments + err = toolDecodeArguments(arguments, &args) + switch { + case b.runs == nil: + err = fmt.Errorf("unknown tool %q", name) + case err == nil: + output, err = b.runs.Tell(ctx, args.Name, args.Message) + } default: err = fmt.Errorf("unknown tool %q", name) } @@ -377,6 +387,12 @@ type ToolSpawnArguments struct { Effort *string `json:"effort"` } +// ToolTellArguments are the arguments of tell. +type ToolTellArguments struct { + Name string `json:"name"` + Message string `json:"message"` +} + // ToolHeadTail keeps the first and the last toolClipBytes bytes written to it, // and counts the bytes between them that it drops. type ToolHeadTail struct { -- 2.53.0