]> git.giacomolli.uk - onechat/blob - subagent_run.go
Run subagents in the background with spawn
[onechat] / subagent_run.go
1 package main
2
3 import (
4         "context"
5         "errors"
6         "fmt"
7         "os"
8         "slices"
9         "strings"
10         "sync"
11 )
12
13 const (
14         // subagentNote is the first message of the log of a subagent.
15         subagentNote = "subagent"
16
17         // subagentNoteEffort follows subagentNote when the subagent has its own
18         // effort.
19         subagentNoteEffort = " at effort "
20 )
21
22 // errSubagentClosed tells that the runs of the subagents ended with the loop.
23 var errSubagentClosed = errors.New("the daemon stops, so no subagent starts")
24
25 // SubagentRuns runs the subagents of one agent in the background. Each run is
26 // a goroutine with its own Chain and its own context, which only Close ends.
27 // The first request of each chain holds the view of the agent at the spawn,
28 // then the state of the turn, then the whole log of the subagent. The report of
29 // the run goes to report: its final answer, or the error of a failed call. At
30 // the stop of the daemon the runs end with no report.
31 //
32 // Spawn splits the view with the layout of the agent, which has no lock. Spawn
33 // is a tool, so it runs in the goroutine of the turns, as each split of a turn
34 // does.
35 type SubagentRuns struct {
36         agent     *Agent
37         memory    *Memory
38         subagents *Subagents
39         layout    *ViewLayout
40         client    ChainClient
41         state     func() string
42         report    func(text string, stopped bool)
43         notify    func(Event)
44
45         mutex   sync.Mutex
46         running map[string]*SubagentRun
47         closed  bool
48         workers sync.WaitGroup
49 }
50
51 // Spawn starts one subagent for each task and returns their names at once. It
52 // waits until the view has no pending summary, and gives the view to each
53 // subagent. A nil effort is the effort of the agent.
54 func (r *SubagentRuns) Spawn(ctx context.Context, tasks []string, effort *string) (string, error) {
55         if len(tasks) == 0 {
56                 return "", errors.New("no tasks")
57         }
58
59         r.mutex.Lock()
60         closed := r.closed
61         r.mutex.Unlock()
62
63         if closed {
64                 return "", errSubagentClosed
65         }
66
67         note := subagentNote
68         runEffort := r.agent.config.Effort
69         if effort != nil && !slices.Contains(promptEfforts, *effort) {
70                 return "", fmt.Errorf("no effort %q: one of %s", *effort, strings.Join(promptEfforts, ", "))
71         }
72
73         if effort != nil && *effort != runEffort {
74                 note += subagentNoteEffort + *effort
75                 runEffort = *effort
76         }
77
78         err := r.memory.Settle(ctx)
79         if err != nil {
80                 return "", fmt.Errorf("wait for the summaries: %w", err)
81         }
82
83         view, err := r.layout.Split(r.memory.ViewLines(nil))
84         if err != nil {
85                 return "", err
86         }
87
88         names := make([]string, 0, len(tasks))
89         for _, task := range tasks {
90                 name, err := r.create(ctx, note, task, view, runEffort)
91                 if err != nil && len(names) > 0 {
92                         return "", fmt.Errorf("started %s, then: %w", strings.Join(names, ", "), err)
93                 }
94
95                 if err != nil {
96                         return "", err
97                 }
98
99                 names = append(names, name)
100         }
101
102         return "Started " + strings.Join(names, ", ") + ". Each one's report will reach you as a message when it finishes.", nil
103 }
104
105 // create makes a new subagent with its note, its task, and its work directory,
106 // and starts its run.
107 func (r *SubagentRuns) create(ctx context.Context, note, task string, view []string, effort string) (string, error) {
108         subagent, err := r.subagents.Create(ctx)
109         if err != nil {
110                 return "", err
111         }
112
113         for _, message := range [][2]string{{"note", note}, {"user", task}} {
114                 _, err = subagent.Append(message[0], message[1])
115                 if err != nil {
116                         return "", err
117                 }
118         }
119
120         err = subagent.Flush(ctx)
121         if err != nil {
122                 return "", err
123         }
124
125         err = os.MkdirAll(subagent.WorkDir(), 0o700)
126         if err != nil {
127                 return "", fmt.Errorf("create work directory: %w", err)
128         }
129
130         return subagent.name, r.start(ctx, &SubagentRun{
131                 subagent: subagent,
132                 view:     view,
133                 effort:   effort,
134         })
135 }
136
137 // start runs run in the background. Its context keeps the values of ctx, but
138 // only Close ends it.
139 func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error {
140         ctx, run.cancel = context.WithCancel(context.WithoutCancel(ctx))
141
142         r.mutex.Lock()
143         if r.closed {
144                 r.mutex.Unlock()
145                 run.cancel()
146
147                 return errSubagentClosed
148         }
149
150         r.running[run.subagent.name] = run
151         r.workers.Add(1)
152         r.mutex.Unlock()
153
154         r.notify(Event{
155                 Kind: "subagent",
156                 Text: run.subagent.name,
157         })
158
159         go r.run(ctx, run)
160
161         return nil
162 }
163
164 // run runs the chain of run and reports its end, unless Close ended it.
165 func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) {
166         defer r.workers.Done()
167         defer run.cancel()
168
169         reply := r.chain(ctx, run)
170
171         r.mutex.Lock()
172         delete(r.running, run.subagent.name)
173         r.mutex.Unlock()
174
175         if ctx.Err() != nil {
176                 return
177         }
178
179         r.report(subagentReport(run.subagent.name, []string{reply}), false)
180 }
181
182 // chain runs one request chain of run and returns its final answer, or the
183 // error of the chain after "failed: ".
184 func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) string {
185         instructions, err := r.agent.SubagentPrompt()
186         if err != nil {
187                 return "failed: " + err.Error()
188         }
189
190         log, err := run.subagent.Show()
191         if err != nil {
192                 return "failed: " + err.Error()
193         }
194
195         input := make([]any, 0, len(run.view)+2)
196         for _, text := range run.view {
197                 input = append(input, modelUserMessage(text))
198         }
199
200         input = append(input, modelUserMessage(r.state()), modelUserMessage(log))
201         chain := &Chain{
202                 client: r.client,
203                 tools: &Toolbox{
204                         log:       run.subagent,
205                         chat:      r.memory,
206                         subagents: r.subagents,
207                         workdir:   run.subagent.WorkDir(),
208                 },
209                 request: ModelRequest{
210                         Model:        r.agent.config.Model,
211                         Effort:       run.effort,
212                         Instructions: instructions,
213                         Tools:        promptSubagentTools(),
214                         Agent:        run.subagent.name,
215                 },
216                 take: func() []Message {
217                         return nil
218                 },
219         }
220
221         reply, err := chain.Run(ctx, input)
222         if err != nil {
223                 return "failed: " + err.Error()
224         }
225
226         return reply
227 }
228
229 // Close ends every run and waits for them. No subagent starts after Close.
230 func (r *SubagentRuns) Close() {
231         r.mutex.Lock()
232         r.closed = true
233         for _, run := range r.running {
234                 run.cancel()
235         }
236         r.mutex.Unlock()
237
238         r.workers.Wait()
239 }
240
241 // SubagentRun is one run of a subagent: the view that its requests get, its
242 // effort, and the end of its context.
243 type SubagentRun struct {
244         subagent *Subagent
245         view     []string
246         effort   string
247         cancel   context.CancelFunc
248 }
249
250 // subagentReport returns the report of the subagent name with its replies: the
251 // name in brackets, a new line, the replies, and the zoom that gives its whole
252 // chat.
253 func subagentReport(name string, replies []string) string {
254         return "[" + name + "]\n" + strings.Join(replies, "\n\n") + "\n\nFull chat: zoom(\"" + name + "\")"
255 }