``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
}
// 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{
},
}),
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,
})
}
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 {
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) {
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
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) {
})
}
+// 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
}
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)
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
"net/http"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
"time"
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{})
// 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
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)
}
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 {