]> git.giacomolli.uk - onechat/commitdiff
Run subagents in the background with spawn
authorAnderson A. Giacomolli <redacted>
Sun, 8 Nov 2026 13:33:12 +0000 (10:33 -0300)
committerAnderson A. Giacomolli <redacted>
Sun, 8 Nov 2026 13:33:23 +0000 (10:33 -0300)
The agent gets the spawn tool: spawn(tasks, effort) starts one subagent
for each task and returns their names at once. It waits until the view
has no pending summary, and gives that view to each subagent. Each
subagent runs in its own goroutine with its own request chain, its own
system prompt with the user's instructions at the end, and the tools
zoom, date, shell, read, write, and edit, in subagents/NAME/work. It
cannot start a subagent. Each request starts with the view, the state,
and the whole log of the subagent as "i|kind: text" lines, and the log
starts with the note "subagent" and the task.

At the end of a run, its final answer, or "failed: ERROR" after a failed
call such as a full context, goes to the agent as one work message:
"[Name]", the reply, and the zoom that gives its whole chat. The report
joins a running turn between its tool calls, or starts a new turn. A
report of a stopped subagent is logged but starts no turn. When the loop
ends at the stop of the daemon, the runs end with no report, and the
loop waits for them. The prompt gets the lines on subagents.

The efforts of spawn are low, medium, high, and xhigh: the efforts of
the reference design that the Responses API also takes.

Issue: b1ac698

README.rst
agent.go
prompt.go
prompt_test.go
subagent.go
subagent_run.go [new file with mode: 0644]
subagent_run_test.go [new file with mode: 0644]
tools.go
turn.go

index e386c67566cacb7e0267bd651ed662a59bc24e0e..82a8e135a9b4d4b9974f640de3c2a2bef4338572 100644 (file)
@@ -88,6 +88,17 @@ login`` and ``onechat probe`` refuse to start until it stops.
 The agent runs commands with bash as the user in its working directory,
 with no sandbox. It has the rights of the user, also on the token file.
 
+When the user asks for it, the agent starts subagents for long tasks.
+Each subagent has a name, such as ``GreenOtter``, and runs in the
+background while the agent answers other messages. It gets the view of
+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``.
+
 ``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
 prompt cache key comes from it.
index 4892840786da9a7ff4567d4c4321cd9e45172dd8..1be2b0a136f54dbfa55573ce4b37e572f9798191 100644 (file)
--- a/agent.go
+++ b/agent.go
@@ -85,12 +85,34 @@ func (a *Agent) CacheKey() string {
 // from instructions.md. It reads the file on each call, so a change applies at
 // the next call. A missing file means no instructions.
 func (a *Agent) Prompt() (string, error) {
+       instructions, err := a.instructions()
+       if err != nil {
+               return "", err
+       }
+
+       return prompt(a.config.Name, instructions), nil
+}
+
+// SubagentPrompt returns the system prompt of a subagent of the agent, with the
+// user's instructions as Prompt reads them.
+func (a *Agent) SubagentPrompt() (string, error) {
+       instructions, err := a.instructions()
+       if err != nil {
+               return "", err
+       }
+
+       return promptSubagent(a.config.Name, instructions), nil
+}
+
+// instructions reads the user's instructions from instructions.md. A missing
+// file means no instructions.
+func (a *Agent) instructions() (string, error) {
        data, err := os.ReadFile(filepath.Join(a.dir, agentInstructionsFileName))
        if err != nil && !errors.Is(err, os.ErrNotExist) {
                return "", fmt.Errorf("read instructions: %w", err)
        }
 
-       return prompt(a.config.Name, string(data)), nil
+       return string(data), nil
 }
 
 // AgentConfig holds the settings of the config.json of an agent. ID is random,
index 71671c510caa6b0c3ec837de6555f1ecc5807e76..8bd96911aef70364b58dfbef8c97d92b9d6fe5b3 100644 (file)
--- a/prompt.go
+++ b/prompt.go
@@ -17,19 +17,30 @@ const (
        // the end of the prompt.
        promptInstructionsClause = ", following the user's instructions\nat the end of this prompt: who they are, how their files are organized and how\nthey want work done"
 
+       // promptSubagentTaskSentence is the sentence of promptSubagentText that
+       // gets promptSubagentInstructionsClause when the user has instructions.
+       promptSubagentTaskSentence = "Do it yourself, with your tools."
+
+       // promptSubagentInstructionsClause points a subagent to the
+       // instructions of the user at the end of its prompt.
+       promptSubagentInstructionsClause = ",\nfollowing the user's instructions at the end of this prompt: they say who the\nuser is, how their files are organized and how they want work done"
+
        // promptInstructionsHeading starts the instructions of the user after
-       // promptText.
+       // promptText or promptSubagentText.
        promptInstructionsHeading = "\n\n# The user's instructions\n\n"
 )
 
+// promptEfforts are the reasoning efforts that spawn can give a subagent.
+var promptEfforts = []string{"low", "medium", "high", "xhigh"}
+
 // promptText is the system prompt of the reference design, nearly word for
 // word. The reference design names its own agent, so promptText has the name of
 // the agent and the kind reply in its place. It describes only what OneChat can
-// do, so it leaves out the spec text on images, subagents, computers, and
-// background tasks. Its paragraph on persistent mode tells the model that a
-// final answer ends the turn. prompt adds the clause on the instructions of the user only
-// when there are instructions. It holds no date and no state, so turns and
-// compactions share its prompt cache.
+// do, so it leaves out the spec text on images, computers, and computer tasks.
+// Its paragraph on persistent mode tells the model that a final answer ends the
+// turn. prompt adds the clause on the instructions of the user only when there
+// are instructions. It holds no date and no state, so turns and compactions
+// share its prompt cache.
 const promptText = `You are {name}, an AI agent that works for one user in a single chat that never
 ends. Each call to you is a turn or a compaction: the view below is followed by
 the user's new message, or by a task starting "Compaction:".
@@ -64,6 +75,7 @@ Tools:
 # Turns
 
 Do the user's tasks yourself, with your tools.
+Use subagents only when the user asks for them.
 
 The view is your memory, and its latest word on a thing is the truth. Whenever
 you need any information, first find its latest mention in the view and zoom
@@ -73,6 +85,10 @@ allowed mechanism to navigate the tree. Summaries keep little of tool output, so
 say in your reply what you learned that will matter later.
 
 Messages the user sends while you work reach you between tool calls.
+Subagents run in the background; each one's report reaches you as a message
+starting "[Name]", between your tool calls or as a new turn. Never wait for one
+(no sleep, no polling): go on, or end your turn and tell the user what is
+running.
 
 You are in persistent mode: a reply with no tool call is your final answer, and
 it ends your turn. You act again only when the user writes. So keep going until
@@ -116,26 +132,85 @@ exactly. Tag each item with its kind ("user: ...; echo: ..."), and credit quoted
 text to its real author. Never make anything look further along than it was. If
 told the line is too long, shorten it. Non-ASCII characters cost 2-4 bytes.`
 
+// promptSubagentText is the system prompt of a subagent, from the subagent
+// prompt of the reference design: its head, then the doc of the view, without
+// the parts on devices and images. A subagent gets the view of the agent, then
+// its whole log, so it goes on from its log after a tell or a restart.
+// promptSubagent adds the clause on the instructions of the user only when
+// there are instructions.
+const promptSubagentText = `You are a subagent of {name}, an AI agent that works for one user in a single
+chat that never ends. {name} gave you a task. Do it yourself, with your tools.
+
+Your first message holds the view below, then your chat with {name} so far, its
+lines as "i|kind: text": what you are (note), your task (user), and later all
+you did and were told. The view shows you what {name} knows: what the user
+wants, decided and taught. Use it as context only, and do what your task says,
+not what the user's last message says, since {name} may have given you just
+part of the work. Your final reply is your report to {name}. {name} may send you
+more messages, even while you work: answer the last.
+
+The view: the whole chat between {name} and the user, oldest first, inside
+<chat> tags, as one-line summaries. Each line is
+
+  id+n|text   the n messages from id on, summarized (newlines shown as spaces)
+
+A summary tags each item with its kind: user (the user's words), reply
+({name}'s replies), tool ({name}'s tool calls), echo (their results), note
+(memories from before this chat), or work (the report of a subagent, starting
+"[Name]"). A short message is its own line, word for word. Recent lines cover
+one message each; the older the messages, the more a line covers. A message not
+summarized yet shows as "(not summarized yet: zoom it)". No message appears in
+full, not even the last ones. A text too long for one message is split over
+several in a row.
+
+Navigating: zoom(id, n) opens line id+n into the two lines of n/2 messages it
+was made from; zoom(id, 1) gives message id in full. Zoom whenever a summary
+only mentions something you need, such as what your last reply said, a
+decision, a past attempt or where a file is, before you act, guess or ask.
+date(id) gives the date and time of message id. zoom("Name") gives an agent's
+whole chat.`
+
 // prompt returns the system prompt of the agent name with the instructions of
 // the user at its end. Without instructions it has neither them nor the clause
 // that points to them. Turns and compactions send the same prompt and tools, so
 // compactions read the prompt cache of the turns.
 func prompt(name, instructions string) string {
-       text := strings.ReplaceAll(promptText, promptName, name)
-       instructions = strings.TrimSpace(instructions)
-       if instructions == "" {
-               return text
-       }
-
-       text = strings.Replace(text, promptTaskSentence, strings.TrimSuffix(promptTaskSentence, ".")+promptInstructionsClause+".", 1)
+       return promptWithInstructions(promptText, promptTaskSentence, promptInstructionsClause, name, instructions)
+}
 
-       return text + promptInstructionsHeading + instructions
+// promptSubagent returns the system prompt of a subagent of the agent name,
+// with the instructions of the user at its end, as prompt does.
+func promptSubagent(name, instructions string) string {
+       return promptWithInstructions(promptSubagentText, promptSubagentTaskSentence, promptSubagentInstructionsClause, name, instructions)
 }
 
-// promptTools returns the function tools of every call: zoom, date, shell,
-// read, write, and edit. A compaction sends them too and never calls them,
-// because a change of the tools ends the shared prompt cache.
+// promptTools returns the function tools of every call of the agent: the tools
+// of promptSubagentTools, then spawn. 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",
+               Name:        "spawn",
+               Description: `Start one subagent per task, in parallel, and return their names at once. Each one's report reaches you as a message "[Name] report" when it finishes. A subagent sees the view and the user's instructions, but knows what you want only from its task.`,
+               Parameters: promptObjectSchema(map[string]any{
+                       "tasks": map[string]any{
+                               "type":        "array",
+                               "items":       map[string]any{"type": "string"},
+                               "description": "One task for each subagent, with all it needs to know.",
+                       },
+                       "effort": map[string]any{
+                               "type":        []string{"string", "null"},
+                               "enum":        promptEffortEnum(),
+                               "description": "How hard they think; null for as hard as you.",
+                       },
+               }),
+               Strict: true,
+       })
+}
+
+// promptSubagentTools returns the function tools of a subagent: zoom, date,
+// shell, read, write, and edit.
+func promptSubagentTools() []ModelTool {
        return []ModelTool{
                {
                        Type:        "function",
@@ -199,6 +274,22 @@ func promptTools() []ModelTool {
        }
 }
 
+// promptWithInstructions returns text with the name of the agent and the
+// instructions of the user at its end, after the heading. With instructions,
+// sentence gets clause, which points to them. Without instructions the prompt
+// has neither them nor the clause.
+func promptWithInstructions(text, sentence, clause, name, instructions string) string {
+       text = strings.ReplaceAll(text, promptName, name)
+       instructions = strings.TrimSpace(instructions)
+       if instructions == "" {
+               return text
+       }
+
+       text = strings.Replace(text, sentence, strings.TrimSuffix(sentence, ".")+clause+".", 1)
+
+       return text + promptInstructionsHeading + instructions
+}
+
 // promptObjectSchema returns a strict JSON schema of an object whose properties
 // are all required.
 func promptObjectSchema(properties map[string]any) map[string]any {
@@ -217,6 +308,17 @@ func promptObjectSchema(properties map[string]any) map[string]any {
        }
 }
 
+// promptEffortEnum returns the values of the effort of spawn: promptEfforts
+// and null.
+func promptEffortEnum() []any {
+       values := make([]any, 0, len(promptEfforts)+1)
+       for _, effort := range promptEfforts {
+               values = append(values, effort)
+       }
+
+       return append(values, nil)
+}
+
 // promptIntegerSchema returns the JSON schema of an integer.
 func promptIntegerSchema(description string) map[string]any {
        return map[string]any{
index 5b10597bb755c1f629eefd823ca588a12573f527..d2f34fa01f1d1d5ce111d18df13744281ad14c05 100644 (file)
@@ -26,11 +26,39 @@ func TestPromptNamesTheAgent(t *testing.T) {
 
 func TestPromptLeavesOutAbsentFeatures(t *testing.T) {
        text := prompt("Ada", "")
-       for _, absent := range []string{"image", "ubagent", "computer", "device", "instructions at the end"} {
+       for _, absent := range []string{"image", "computer", "device", "instructions at the end"} {
                require.NotContains(t, text, absent)
        }
 }
 
+func TestPromptHasTheLinesOnSubagents(t *testing.T) {
+       text := prompt("Ada", "")
+       require.Contains(t, text, "Do the user's tasks yourself, with your tools.\nUse subagents only when the user asks for them.\n")
+       require.Contains(t, text, `Messages the user sends while you work reach you between tool calls.
+Subagents run in the background; each one's report reaches you as a message
+starting "[Name]", between your tool calls or as a new turn. Never wait for one
+(no sleep, no polling): go on, or end your turn and tell the user what is
+running.
+`)
+}
+
+func TestSubagentPromptNamesTheAgentAndEndsWithTheInstructions(t *testing.T) {
+       text := promptSubagent("Ada", "\nI am Lena.\n")
+       require.True(t, strings.HasPrefix(text, "You are a subagent of Ada, an AI agent"))
+       require.Contains(t, text, "Ada gave you a task. Do it yourself, with your tools,\nfollowing the user's instructions at the end of this prompt: they say who the\nuser is, how their files are organized and how they want work done.\n")
+       require.Contains(t, text, "zoom(\"Name\") gives an agent's\nwhole chat.")
+       require.True(t, strings.HasSuffix(text, "\n\n# The user's instructions\n\nI am Lena."))
+       require.NotContains(t, text, promptName)
+
+       for _, absent := range []string{"image", "computer", "device", "Compaction"} {
+               require.NotContains(t, text, absent)
+       }
+
+       text = promptSubagent("Ada", " \n")
+       require.Contains(t, text, "Ada gave you a task. Do it yourself, with your tools.\n")
+       require.NotContains(t, text, "instructions")
+}
+
 func TestPromptEndsWithTheInstructions(t *testing.T) {
        text := prompt("Ada", "\nI am Lena. Keep replies short.\n\n")
        require.Contains(t, text, `Do the user's tasks yourself, with your tools, following the user's instructions
@@ -49,12 +77,8 @@ func TestPromptWithoutInstructionsHasNoClause(t *testing.T) {
 
 func TestAgentToolsAreStrictAndStable(t *testing.T) {
        tools := promptTools()
-       names := make([]string, len(tools))
-       for n, tool := range tools {
-               names[n] = tool.Name
-       }
-
-       require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, names)
+       require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn"}, promptToolNames(tools))
+       require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, promptToolNames(promptSubagentTools()))
 
        for _, tool := range tools {
                schema := tool.Parameters.(map[string]any)
@@ -73,3 +97,13 @@ func TestAgentToolsAreStrictAndStable(t *testing.T) {
                require.Equal(t, first, again)
        }
 }
+
+// promptToolNames returns the names of tools.
+func promptToolNames(tools []ModelTool) []string {
+       names := make([]string, len(tools))
+       for n, tool := range tools {
+               names[n] = tool.Name
+       }
+
+       return names
+}
index 024875161aae991f57028182c1e560ad7983745a..596c03818510460456b8cbead078bf0051aeee3f 100644 (file)
@@ -115,6 +115,7 @@ func (s *Subagents) Get(ctx context.Context, name string) (*Subagent, error) {
 
        subagent = &Subagent{
                name:   name,
+               dir:    root,
                log:    log,
                syncer: syncer,
        }
@@ -137,10 +138,12 @@ func (s *Subagents) Close() error {
        return err
 }
 
-// Subagent is one subagent and its log. Its mutex protects the log, because
-// the run of the subagent writes it while the agent reads it with zoom.
+// Subagent is one subagent, its directory, and its log. Its mutex protects the
+// log, because the run of the subagent writes it while the agent reads it with
+// zoom.
 type Subagent struct {
        name   string
+       dir    string
        mutex  sync.Mutex
        log    *Log
        syncer *Syncer
@@ -185,6 +188,12 @@ func (a *Subagent) Show() (string, error) {
        return strings.Join(lines, "\n\n"), nil
 }
 
+// WorkDir returns the directory where the tools of the subagent run: work in
+// its directory.
+func (a *Subagent) WorkDir() string {
+       return filepath.Join(a.dir, "work")
+}
+
 // close makes the writes durable and closes the log.
 func (a *Subagent) close() error {
        a.mutex.Lock()
diff --git a/subagent_run.go b/subagent_run.go
new file mode 100644 (file)
index 0000000..3ee98a0
--- /dev/null
@@ -0,0 +1,255 @@
+package main
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "os"
+       "slices"
+       "strings"
+       "sync"
+)
+
+const (
+       // subagentNote is the first message of the log of a subagent.
+       subagentNote = "subagent"
+
+       // subagentNoteEffort follows subagentNote when the subagent has its own
+       // effort.
+       subagentNoteEffort = " at effort "
+)
+
+// errSubagentClosed tells that the runs of the subagents ended with the loop.
+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.
+//
+// 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.
+type SubagentRuns struct {
+       agent     *Agent
+       memory    *Memory
+       subagents *Subagents
+       layout    *ViewLayout
+       client    ChainClient
+       state     func() string
+       report    func(text string, stopped bool)
+       notify    func(Event)
+
+       mutex   sync.Mutex
+       running map[string]*SubagentRun
+       closed  bool
+       workers sync.WaitGroup
+}
+
+// Spawn starts one subagent for each task and returns their names at once. It
+// waits until the view has no pending summary, and gives the view to each
+// subagent. A nil effort is the effort of the agent.
+func (r *SubagentRuns) Spawn(ctx context.Context, tasks []string, effort *string) (string, error) {
+       if len(tasks) == 0 {
+               return "", errors.New("no tasks")
+       }
+
+       r.mutex.Lock()
+       closed := r.closed
+       r.mutex.Unlock()
+
+       if closed {
+               return "", errSubagentClosed
+       }
+
+       note := subagentNote
+       runEffort := r.agent.config.Effort
+       if effort != nil && !slices.Contains(promptEfforts, *effort) {
+               return "", fmt.Errorf("no effort %q: one of %s", *effort, strings.Join(promptEfforts, ", "))
+       }
+
+       if effort != nil && *effort != runEffort {
+               note += subagentNoteEffort + *effort
+               runEffort = *effort
+       }
+
+       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
+       }
+
+       names := make([]string, 0, len(tasks))
+       for _, task := range tasks {
+               name, err := r.create(ctx, note, task, view, runEffort)
+               if err != nil && len(names) > 0 {
+                       return "", fmt.Errorf("started %s, then: %w", strings.Join(names, ", "), err)
+               }
+
+               if err != nil {
+                       return "", err
+               }
+
+               names = append(names, name)
+       }
+
+       return "Started " + strings.Join(names, ", ") + ". Each one's report will reach you as a message when it finishes.", nil
+}
+
+// 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) {
+       subagent, err := r.subagents.Create(ctx)
+       if err != nil {
+               return "", err
+       }
+
+       for _, message := range [][2]string{{"note", note}, {"user", task}} {
+               _, err = subagent.Append(message[0], message[1])
+               if err != nil {
+                       return "", err
+               }
+       }
+
+       err = subagent.Flush(ctx)
+       if err != nil {
+               return "", err
+       }
+
+       err = os.MkdirAll(subagent.WorkDir(), 0o700)
+       if err != nil {
+               return "", fmt.Errorf("create work directory: %w", err)
+       }
+
+       return subagent.name, r.start(ctx, &SubagentRun{
+               subagent: subagent,
+               view:     view,
+               effort:   effort,
+       })
+}
+
+// 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 {
+               r.mutex.Unlock()
+               run.cancel()
+
+               return errSubagentClosed
+       }
+
+       r.running[run.subagent.name] = run
+       r.workers.Add(1)
+       r.mutex.Unlock()
+
+       r.notify(Event{
+               Kind: "subagent",
+               Text: run.subagent.name,
+       })
+
+       go r.run(ctx, run)
+
+       return nil
+}
+
+// run runs the chain of run and reports its end, unless Close ended it.
+func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) {
+       defer r.workers.Done()
+       defer run.cancel()
+
+       reply := r.chain(ctx, run)
+
+       r.mutex.Lock()
+       delete(r.running, run.subagent.name)
+       r.mutex.Unlock()
+
+       if ctx.Err() != nil {
+               return
+       }
+
+       r.report(subagentReport(run.subagent.name, []string{reply}), 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 {
+       instructions, err := r.agent.SubagentPrompt()
+       if err != nil {
+               return "failed: " + err.Error()
+       }
+
+       log, err := run.subagent.Show()
+       if err != nil {
+               return "failed: " + err.Error()
+       }
+
+       input := make([]any, 0, len(run.view)+2)
+       for _, text := range run.view {
+               input = append(input, modelUserMessage(text))
+       }
+
+       input = append(input, modelUserMessage(r.state()), modelUserMessage(log))
+       chain := &Chain{
+               client: r.client,
+               tools: &Toolbox{
+                       log:       run.subagent,
+                       chat:      r.memory,
+                       subagents: r.subagents,
+                       workdir:   run.subagent.WorkDir(),
+               },
+               request: ModelRequest{
+                       Model:        r.agent.config.Model,
+                       Effort:       run.effort,
+                       Instructions: instructions,
+                       Tools:        promptSubagentTools(),
+                       Agent:        run.subagent.name,
+               },
+               take: func() []Message {
+                       return nil
+               },
+       }
+
+       reply, err := chain.Run(ctx, input)
+       if err != nil {
+               return "failed: " + err.Error()
+       }
+
+       return reply
+}
+
+// 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()
+}
+
+// SubagentRun is one run of a subagent: the view that its requests get, its
+// effort, and the end of its context.
+type SubagentRun struct {
+       subagent *Subagent
+       view     []string
+       effort   string
+       cancel   context.CancelFunc
+}
+
+// subagentReport returns the report of the subagent name with its replies: the
+// name in brackets, a new line, the replies, and the zoom that gives its whole
+// chat.
+func subagentReport(name string, replies []string) string {
+       return "[" + name + "]\n" + strings.Join(replies, "\n\n") + "\n\nFull chat: zoom(\"" + name + "\")"
+}
diff --git a/subagent_run_test.go b/subagent_run_test.go
new file mode 100644 (file)
index 0000000..3027fe6
--- /dev/null
@@ -0,0 +1,283 @@
+package main
+
+import (
+       "io"
+       "net/http"
+       "os"
+       "path/filepath"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+// subagentTestFullContext is the error body of a call whose input does not fit
+// in the context of the model.
+const subagentTestFullContext = `{"error": {"message": "Your input exceeds the context window of this model.", "type": "invalid_request_error", "code": "context_length_exceeded"}}`
+
+func TestSpawnRunsASubagentInTheBackground(t *testing.T) {
+       server := newModelFake(t)
+       var test *TurnTestLoop
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               last := subagentTestLastItem(call)
+               switch {
+               case subagentTestCall(call) && last["type"] == "function_call_output":
+                       // The report comes after the reply of the turn of the spawn.
+                       test.events.WaitFor(t, "reply", 1)
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
+               case subagentTestCall(call):
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"pwd"}`)}, 100, 0, 5))
+               case last["type"] == "function_call_output":
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
+               case strings.HasPrefix(subagentTestText(last), "["):
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Noted.")}, 100, 0, 5))
+               default:
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
+               }
+       }
+
+       test = newTestTurnLoop(t, server)
+       _, err := test.loop.Submit(terminalSource, "Count the files in a subagent.")
+       require.NoError(t, err)
+       test.events.WaitFor(t, "idle", 2)
+
+       name := test.events.Last("subagent")
+       require.True(t, subagentNameValid(name), name)
+       work := filepath.Join(test.loop.runs.subagents.dir, name, "work")
+       require.DirExists(t, work)
+
+       report := "[" + name + "]\nThere are 3 files.\n\nFull chat: zoom(\"" + name + "\")"
+       require.Equal(t, []string{
+               "user: Count the files in a subagent.",
+               `tool: spawn {"tasks":["Count the files."],"effort":null}`,
+               "echo: Started " + name + ". Each one's report will reach you as a message when it finishes.",
+               "reply: Started.",
+               "work: " + report,
+               "reply: Noted.",
+       }, test.Log(t))
+
+       var first, second *ModelFakeCall
+       for n := range server.Calls() {
+               call := server.Call(n)
+               if !subagentTestCall(call) {
+                       continue
+               }
+
+               if first == nil {
+                       first = &call
+               } else {
+                       second = &call
+               }
+       }
+
+       require.NotNil(t, second)
+
+       // The first request of the subagent holds the view, the state, and its
+       // whole log, with its own prompt and tools.
+       texts := turnInputTexts(t, *first)
+       view := strings.Join(texts[:len(texts)-2], "")
+       require.True(t, strings.HasPrefix(view, "<chat>\n0+1|user: Count the files in a subagent.\n1+1|tool: spawn"), view)
+       require.Equal(t, "</chat>\n\nNow: Thursday 2026-01-01 12:00 +00:00", texts[len(texts)-2])
+       require.Equal(t, "0|note: subagent\n\n1|user: Count the files.", texts[len(texts)-1])
+
+       instructions, err := test.agent.SubagentPrompt()
+       require.NoError(t, err)
+       require.Equal(t, instructions, first.body["instructions"])
+
+       var tools []string
+       for _, tool := range first.body["tools"].([]any) {
+               tools = append(tools, tool.(map[string]any)["name"].(string))
+       }
+
+       require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, tools)
+       require.Equal(t, work+"\n", subagentTestLastItem(*second)["output"])
+
+       subagent, err := test.loop.runs.subagents.Get(t.Context(), name)
+       require.NoError(t, err)
+
+       log, err := subagent.Show()
+       require.NoError(t, err)
+       require.Equal(t, "0|note: subagent\n\n1|user: Count the files.\n\n2|tool: shell {\"command\":\"pwd\"}\n\n3|echo: "+work+"\n\n\n4|reply: There are 3 files.", log)
+
+       var agents []string
+       for _, record := range modelReadUsage(t, test.usagePath) {
+               agents = append(agents, record.Agent)
+       }
+
+       require.ElementsMatch(t, []string{"", "", "", name, name}, agents)
+}
+
+func TestReportJoinsTheNextRequestOfARunningTurn(t *testing.T) {
+       server := newModelFake(t)
+       arrived := make(chan struct{})
+       release := make(chan struct{})
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               if call.number == 0 {
+                       close(arrived)
+                       <-release
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
+
+                       return
+               }
+
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("done")}, 100, 0, 5))
+       }
+
+       test := newTestTurnLoop(t, server)
+       _, err := test.loop.Submit(terminalSource, "first")
+       require.NoError(t, err)
+
+       <-arrived
+       report := subagentReport("GreenOtter", []string{"It is done."})
+       test.loop.Report(report, false)
+       close(release)
+
+       test.events.WaitFor(t, "idle", 1)
+       require.Equal(t, report, subagentTestText(subagentTestLastItem(server.Call(1))))
+
+       time.Sleep(50 * time.Millisecond)
+       require.Equal(t, 2, server.Calls())
+       require.Equal(t, 1, test.events.Count("busy"))
+}
+
+func TestReportOfAStoppedSubagentStartsNoTurn(t *testing.T) {
+       server := newModelFake(t)
+       test := newTestTurnLoop(t, server)
+       report := subagentReport("GreenOtter", []string{"failed: context canceled"})
+       test.loop.Report(report, true)
+
+       test.events.WaitFor(t, "work", 1)
+       time.Sleep(50 * time.Millisecond)
+       require.Equal(t, []string{"work: " + report}, test.Log(t))
+       require.Zero(t, server.Calls())
+       require.Zero(t, test.events.Count("busy"))
+}
+
+func TestFullContextGivesAFailedReport(t *testing.T) {
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               if subagentTestCall(call) {
+                       w.WriteHeader(http.StatusBadRequest)
+                       _, _ = io.WriteString(w, subagentTestFullContext)
+
+                       return
+               }
+
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It failed.")}, 100, 0, 5))
+       }
+
+       test := newTestTurnLoop(t, server)
+       _, err := test.loop.runs.Spawn(t.Context(), []string{"Read the big file."}, nil)
+       require.NoError(t, err)
+       test.events.WaitFor(t, "idle", 1)
+
+       name := test.events.Last("subagent")
+       require.Equal(t, []string{
+               "work: [" + name + "]\nfailed: model call: HTTP 400: " + subagentTestFullContext + "\n\nFull chat: zoom(\"" + name + "\")",
+               "reply: It failed.",
+       }, test.Log(t))
+}
+
+func TestSubagentHasItsOwnEffortAndNoSpawn(t *testing.T) {
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               if subagentTestCall(call) && subagentTestLastItem(call)["type"] != "function_call_output" {
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["More."],"effort":null}`)}, 100, 0, 5))
+
+                       return
+               }
+
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
+       }
+
+       test := newTestTurnLoop(t, server)
+       runs := test.loop.runs
+       _, err := runs.Spawn(t.Context(), nil, nil)
+       require.EqualError(t, err, "no tasks")
+
+       effort := "max"
+       _, err = runs.Spawn(t.Context(), []string{"Think."}, &effort)
+       require.EqualError(t, err, `no effort "max": one of low, medium, high, xhigh`)
+
+       effort = "high"
+       _, err = runs.Spawn(t.Context(), []string{"Think hard."}, &effort)
+       require.NoError(t, err)
+       test.events.WaitFor(t, "idle", 1)
+
+       for n := range server.Calls() {
+               call := server.Call(n)
+               if subagentTestCall(call) {
+                       require.Equal(t, "high", call.body["reasoning"].(map[string]any)["effort"])
+               }
+       }
+
+       subagent, err := runs.subagents.Get(t.Context(), test.events.Last("subagent"))
+       require.NoError(t, err)
+
+       log, err := subagent.Show()
+       require.NoError(t, err)
+       require.Equal(t, "0|note: subagent at effort high\n\n1|user: Think hard.\n\n2|tool: spawn {\"tasks\":[\"More.\"],\"effort\":null}\n\n3|echo: error: unknown tool \"spawn\"\n\n4|reply: ok", log)
+
+       entries, err := os.ReadDir(runs.subagents.dir)
+       require.NoError(t, err)
+       require.Len(t, entries, 1)
+}
+
+func TestCloseEndsTheSubagentsWithNoReport(t *testing.T) {
+       server := newModelFake(t)
+       arrived := make(chan struct{})
+       release := make(chan struct{})
+       t.Cleanup(func() {
+               close(release)
+       })
+
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               close(arrived)
+               <-release
+       }
+
+       test := newTestTurnLoop(t, server)
+       runs := test.loop.runs
+       _, err := runs.Spawn(t.Context(), []string{"Wait."}, nil)
+       require.NoError(t, err)
+
+       <-arrived
+       runs.Close()
+       require.Empty(t, runs.running)
+       require.Empty(t, test.Log(t))
+
+       _, err = runs.Spawn(t.Context(), []string{"Again."}, nil)
+       require.ErrorIs(t, err, errSubagentClosed)
+
+       entries, err := os.ReadDir(runs.subagents.dir)
+       require.NoError(t, err)
+       require.Len(t, entries, 1)
+}
+
+// subagentTestCall tells if call is a call of a subagent.
+func subagentTestCall(call ModelFakeCall) bool {
+       instructions, _ := call.body["instructions"].(string)
+
+       return strings.HasPrefix(instructions, "You are a subagent")
+}
+
+// subagentTestLastItem returns the last input item of call.
+func subagentTestLastItem(call ModelFakeCall) map[string]any {
+       input := call.body["input"].([]any)
+
+       return input[len(input)-1].(map[string]any)
+}
+
+// subagentTestText returns the text of the user message item, or "" for
+// another item.
+func subagentTestText(item map[string]any) string {
+       content, _ := item["content"].([]any)
+       var text strings.Builder
+       for _, part := range content {
+               value, _ := part.(map[string]any)["text"].(string)
+               text.WriteString(value)
+       }
+
+       return text.String()
+}
index 5329446ff4b3d0ef8627fd7a13140853fb25c360..737618e91bd77851ebda0d658ca9dec0cda4d4a7 100644 (file)
--- a/tools.go
+++ b/tools.go
@@ -50,10 +50,13 @@ 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.
 type Toolbox struct {
        log       ToolLog
        chat      ToolChat
        subagents *Subagents
+       runs      *SubagentRuns
        workdir   string
 }
 
@@ -130,6 +133,15 @@ func (b *Toolbox) run(ctx context.Context, name, arguments string) string {
                if err == nil {
                        output, err = b.edit(args.Path, args.Old, args.New)
                }
+       case "spawn":
+               var args ToolSpawnArguments
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case b.runs == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output, err = b.runs.Spawn(ctx, args.Tasks, args.Effort)
+               }
        default:
                err = fmt.Errorf("unknown tool %q", name)
        }
@@ -358,6 +370,13 @@ type ToolEditArguments struct {
        New  string `json:"new"`
 }
 
+// ToolSpawnArguments are the arguments of spawn. A nil Effort is the effort of
+// the agent.
+type ToolSpawnArguments struct {
+       Tasks  []string `json:"tasks"`
+       Effort *string  `json:"effort"`
+}
+
 // ToolHeadTail keeps the first and the last toolClipBytes bytes written to it,
 // and counts the bytes between them that it drops.
 type ToolHeadTail struct {
diff --git a/turn.go b/turn.go
index 84f788337e80a7ea79b21461337b62ca68c24141..02a4a28513d3b6419d24b1e1b5929e1652a6a315 100644 (file)
--- a/turn.go
+++ b/turn.go
@@ -3,6 +3,7 @@ package main
 import (
        "context"
        "errors"
+       "fmt"
        "path/filepath"
        "sync"
        "time"
@@ -27,10 +28,23 @@ func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, client Chai
                wake:   make(chan struct{}, 1),
        }
 
+       l.runs = &SubagentRuns{
+               agent:     agent,
+               memory:    memory,
+               subagents: subagents,
+               layout:    layout,
+               client:    client,
+               state:     l.state,
+               report:    l.Report,
+               notify:    notify,
+               running:   make(map[string]*SubagentRun),
+       }
+
        l.tools = &Toolbox{
                log:       l,
                chat:      memory,
                subagents: subagents,
+               runs:      l.runs,
                workdir:   agent.config.Workdir,
        }
 
@@ -38,8 +52,8 @@ func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, client Chai
 }
 
 // Event tells the clients what the agent does as it happens. Kind is the kind
-// of a logged message (user, reply, tool, echo), or one of ready, busy, idle,
-// and error. ID is the ID of a logged message. Source names the client that
+// of a logged message (user, reply, tool, echo, work), or one of ready, busy,
+// idle, error, and subagent, which tells that the subagent Text starts a run. ID is the ID of a logged message. Source names the client that
 // sent a user message, such as terminal or telegram, so that each client can
 // show the messages of the others.
 type Event struct {
@@ -57,12 +71,14 @@ type Event struct {
 // The view goes out as the messages of a ViewLayout, so the first request of a
 // turn reads the view of the turn before from the prompt cache. User messages
 // that come during a turn join the next request of its chain after the tool
-// results.
+// results, and so do the reports of the subagents. The subagents run until the
+// loop ends.
 type TurnLoop struct {
        agent  *Agent
        memory *Memory
        client ChainClient
        tools  *Toolbox
+       runs   *SubagentRuns
        layout *ViewLayout
        clock  func() time.Time
        notify func(Event)
@@ -71,6 +87,7 @@ type TurnLoop struct {
        mutex   sync.Mutex
        pending []Message
        cancel  context.CancelFunc
+       failure error
 }
 
 // Submit logs a user message from the client source and wakes the loop. The
@@ -94,6 +111,28 @@ func (l *TurnLoop) Submit(source, text string) (Message, error) {
        return message, nil
 }
 
+// Report logs the report text of a subagent as a work message and tells the
+// clients. Unless the subagent was stopped, the report joins the waiting
+// messages and wakes the loop, as a user message of Submit does. A failed write
+// stops the loop, because the log refuses all writes after it.
+func (l *TurnLoop) Report(text string, stopped bool) {
+       message, err := l.record("work", "", text)
+
+       l.mutex.Lock()
+       switch {
+       case err != nil:
+               l.failure = fmt.Errorf("%w: %w", errChainLogWrite, err)
+       case !stopped:
+               l.pending = append(l.pending, message)
+       }
+       l.mutex.Unlock()
+
+       select {
+       case l.wake <- struct{}{}:
+       default:
+       }
+}
+
 // Interrupt ends the running turn. A running tool is killed, and the turn sends
 // no further request.
 func (l *TurnLoop) Interrupt() bool {
@@ -128,9 +167,12 @@ func (l *TurnLoop) Flush(ctx context.Context) error {
        return l.memory.Flush(ctx)
 }
 
-// Run runs a turn each time user messages wait, until ctx ends. It returns the
-// error of a failed log write, which stops the agent.
+// Run runs a turn each time user messages or reports wait, until ctx ends. It
+// returns the error of a failed log write, which stops the agent. At its end
+// it ends the runs of the subagents and waits for them.
 func (l *TurnLoop) Run(ctx context.Context) error {
+       defer l.runs.Close()
+
        for {
                for l.waiting() {
                        err := l.turn(ctx)
@@ -144,6 +186,11 @@ func (l *TurnLoop) Run(ctx context.Context) error {
                case <-ctx.Done():
                        return ctx.Err()
                }
+
+               err := l.failed()
+               if err != nil {
+                       return err
+               }
        }
 }
 
@@ -165,7 +212,7 @@ func (l *TurnLoop) record(kind, source, text string) (Message, error) {
        return message, nil
 }
 
-// waiting tells if user messages wait for a request.
+// waiting tells if user messages or reports wait for a request.
 func (l *TurnLoop) waiting() bool {
        l.mutex.Lock()
        defer l.mutex.Unlock()
@@ -173,6 +220,14 @@ func (l *TurnLoop) waiting() bool {
        return len(l.pending) > 0
 }
 
+// failed returns the error of a failed write of a report.
+func (l *TurnLoop) failed() error {
+       l.mutex.Lock()
+       defer l.mutex.Unlock()
+
+       return l.failure
+}
+
 // turn runs one turn. A failed or interrupted model call ends the turn and
 // tells the clients. The messages of the turn stay in the log and the view, and
 // the next turn starts only with a new message, so a lasting failure such as a