]> git.giacomolli.uk - onechat/blob - subagent_run.go
Show the subagents in the clients
[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 // The statuses of the end of a run, in the work event of its report.
23 const (
24         subagentFinished = "finished"
25         subagentStopped  = "stopped"
26         subagentFailed   = "failed"
27 )
28
29 // errSubagentClosed tells that the runs of the subagents ended with the loop.
30 var errSubagentClosed = errors.New("the daemon stops, so no subagent starts")
31
32 // SubagentRuns runs the subagents of one agent in the background. Each run is a
33 // goroutine with its own context, which only Stop and Close end. A run is one
34 // or more request chains. The first request of each chain holds the view of the
35 // agent at the start of the run, then the state of the turn, then the whole log
36 // of the subagent. A message of Tell that comes during a chain joins its next
37 // request after the tool results. One that comes after the final answer starts
38 // one more chain, which reads the log again. The report of the run goes to
39 // report: the final answer of each chain, or the error of a failed call. Stop
40 // ends one run, and its report says that it stopped. At the stop of the daemon
41 // the runs end with no report.
42 //
43 // Spawn and Tell split the view with the layout of the agent, which has no
44 // lock. They are tools, so they run in the goroutine of the turns, as each
45 // split of a turn does.
46 type SubagentRuns struct {
47         agent     *Agent
48         memory    *Memory
49         subagents *Subagents
50         layout    *ViewLayout
51         client    ChainClient
52         state     func() string
53         report    func(name, text, status string)
54         note      func(text string) error
55         notify    func(Event)
56
57         mutex   sync.Mutex
58         running map[string]*SubagentRun
59         closed  bool
60         workers sync.WaitGroup
61 }
62
63 // Spawn starts one subagent for each task and returns their names at once. It
64 // waits until the view has no pending summary, and gives the view to each
65 // subagent. A nil effort is the effort of the agent.
66 func (r *SubagentRuns) Spawn(ctx context.Context, tasks []string, effort *string) (string, error) {
67         if len(tasks) == 0 {
68                 return "", errors.New("no tasks")
69         }
70
71         r.mutex.Lock()
72         closed := r.closed
73         r.mutex.Unlock()
74
75         if closed {
76                 return "", errSubagentClosed
77         }
78
79         note := subagentNote
80         runEffort := r.agent.config.Effort
81         if effort != nil && !slices.Contains(promptEfforts, *effort) {
82                 return "", fmt.Errorf("no effort %q: one of %s", *effort, strings.Join(promptEfforts, ", "))
83         }
84
85         if effort != nil && *effort != runEffort {
86                 note += subagentNoteEffort + *effort
87                 runEffort = *effort
88         }
89
90         err := r.memory.Settle(ctx)
91         if err != nil {
92                 return "", fmt.Errorf("wait for the summaries: %w", err)
93         }
94
95         view, err := r.layout.Split(r.memory.ViewLines(nil))
96         if err != nil {
97                 return "", err
98         }
99
100         names := make([]string, 0, len(tasks))
101         for _, task := range tasks {
102                 name, err := r.create(ctx, note, task, view, runEffort)
103                 if err != nil && len(names) > 0 {
104                         return "", fmt.Errorf("started %s, then: %w", strings.Join(names, ", "), err)
105                 }
106
107                 if err != nil {
108                         return "", err
109                 }
110
111                 names = append(names, name)
112         }
113
114         return "Started " + strings.Join(names, ", ") + ". Each one's report will reach you as a message when it finishes.", nil
115 }
116
117 // Tell sends message to the subagent name. A running subagent reads it between
118 // its tool calls, and its report answers it. A finished subagent goes on from
119 // its log with a new run, with the view of now and the effort of its note.
120 func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, error) {
121         r.mutex.Lock()
122         run, found := r.running[name]
123         if found {
124                 defer r.mutex.Unlock()
125
126                 told, err := run.subagent.Append("user", message)
127                 if err != nil {
128                         return "", err
129                 }
130
131                 run.pending = append(run.pending, told)
132
133                 return "Sent. " + name + " reads it between its tool calls, and its report answers it.", nil
134         }
135
136         closed := r.closed
137         r.mutex.Unlock()
138
139         if closed {
140                 return "", errSubagentClosed
141         }
142
143         subagent, err := r.subagents.Get(ctx, name)
144         if err != nil {
145                 return "", err
146         }
147
148         effort, err := r.effort(subagent)
149         if err != nil {
150                 return "", err
151         }
152
153         err = r.memory.Settle(ctx)
154         if err != nil {
155                 return "", fmt.Errorf("wait for the summaries: %w", err)
156         }
157
158         view, err := r.layout.Split(r.memory.ViewLines(nil))
159         if err != nil {
160                 return "", err
161         }
162
163         _, err = subagent.Append("user", message)
164         if err == nil {
165                 err = subagent.Flush(ctx)
166         }
167
168         if err != nil {
169                 return "", err
170         }
171
172         err = r.start(ctx, &SubagentRun{
173                 subagent: subagent,
174                 view:     view,
175                 effort:   effort,
176         })
177         if err != nil {
178                 return "", err
179         }
180
181         return "Sent. " + name + "'s reply will reach you as a message.", nil
182 }
183
184 // Stop ends the run of the subagent name and tells if it ran. The chat gets the
185 // note "Stopped by the user: NAME." under the mutex, so the note comes before
186 // the report of the run.
187 func (r *SubagentRuns) Stop(name string) (bool, error) {
188         r.mutex.Lock()
189         defer r.mutex.Unlock()
190
191         run, found := r.running[name]
192         if !found {
193                 return false, nil
194         }
195
196         err := r.note("Stopped by the user: " + name + ".")
197         if err != nil {
198                 return true, err
199         }
200
201         run.stopped = true
202         run.cancel()
203
204         return true, nil
205 }
206
207 // Close ends every run and waits for them. No subagent starts after Close.
208 func (r *SubagentRuns) Close() {
209         r.mutex.Lock()
210         r.closed = true
211         for _, run := range r.running {
212                 run.cancel()
213         }
214         r.mutex.Unlock()
215
216         r.workers.Wait()
217 }
218
219 // create makes a new subagent with its note, its task, and its work directory,
220 // and starts its run.
221 func (r *SubagentRuns) create(ctx context.Context, note, task string, view []string, effort string) (string, error) {
222         subagent, err := r.subagents.Create(ctx)
223         if err != nil {
224                 return "", err
225         }
226
227         for _, message := range [][2]string{{"note", note}, {"user", task}} {
228                 _, err = subagent.Append(message[0], message[1])
229                 if err != nil {
230                         return "", err
231                 }
232         }
233
234         err = subagent.Flush(ctx)
235         if err != nil {
236                 return "", err
237         }
238
239         err = os.MkdirAll(subagent.WorkDir(), 0o700)
240         if err != nil {
241                 return "", fmt.Errorf("create work directory: %w", err)
242         }
243
244         return subagent.name, r.start(ctx, &SubagentRun{
245                 subagent: subagent,
246                 view:     view,
247                 effort:   effort,
248         })
249 }
250
251 // effort returns the effort of subagent from the note at the start of its log:
252 // the effort of the agent, or the effort that the note names.
253 func (r *SubagentRuns) effort(subagent *Subagent) (string, error) {
254         note, err := subagent.First()
255         if err != nil {
256                 return "", err
257         }
258
259         effort, own := strings.CutPrefix(note.Text, subagentNote+subagentNoteEffort)
260         switch {
261         case note.Kind != "note":
262         case own:
263                 return effort, nil
264         case note.Text == subagentNote:
265                 return r.agent.config.Effort, nil
266         }
267
268         return "", fmt.Errorf("the log of %s does not start with a subagent note", subagent.name)
269 }
270
271 // start runs run in the background. Its context keeps the values of ctx, but
272 // only Stop and Close end it.
273 func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error {
274         ctx, run.cancel = context.WithCancel(context.WithoutCancel(ctx))
275
276         r.mutex.Lock()
277         _, found := r.running[run.subagent.name]
278         if r.closed || found {
279                 r.mutex.Unlock()
280                 run.cancel()
281
282                 if found {
283                         return fmt.Errorf("%s runs already", run.subagent.name)
284                 }
285
286                 return errSubagentClosed
287         }
288
289         r.running[run.subagent.name] = run
290         r.workers.Add(1)
291         r.mutex.Unlock()
292
293         r.notify(Event{
294                 Kind: "subagent",
295                 Text: run.subagent.name,
296         })
297
298         go r.run(ctx, run)
299
300         return nil
301 }
302
303 // run runs the chains of run and reports its end, unless Close ended it. A
304 // chain follows while messages wait after the final answer of the chain before
305 // it, and no chain failed. A chain that Stop ends gives the reply "stopped".
306 func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) {
307         defer r.workers.Done()
308         defer run.cancel()
309
310         var replies []string
311         stopped := false
312         status := subagentFinished
313         for more := true; more; {
314                 reply, err := r.chain(ctx, run)
315
316                 r.mutex.Lock()
317                 stopped = run.stopped
318                 switch {
319                 case err != nil && stopped:
320                         reply = "stopped"
321                 case err != nil:
322                         reply = "failed: " + err.Error()
323                         status = subagentFailed
324                 }
325
326                 more = err == nil && ctx.Err() == nil && len(run.pending) > 0
327                 run.pending = nil
328                 if !more {
329                         delete(r.running, run.subagent.name)
330                 }
331                 r.mutex.Unlock()
332
333                 replies = append(replies, reply)
334         }
335
336         if ctx.Err() != nil && !stopped {
337                 return
338         }
339
340         if stopped {
341                 status = subagentStopped
342         }
343
344         r.report(run.subagent.name, subagentReport(run.subagent.name, replies), status)
345 }
346
347 // chain runs one request chain of run and returns its final answer.
348 func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) (string, error) {
349         instructions, err := r.agent.SubagentPrompt()
350         if err != nil {
351                 return "", err
352         }
353
354         log, err := run.subagent.Show()
355         if err != nil {
356                 return "", err
357         }
358
359         input := make([]any, 0, len(run.view)+2)
360         for _, text := range run.view {
361                 input = append(input, modelUserMessage(text))
362         }
363
364         input = append(input, modelUserMessage(r.state()), modelUserMessage(log))
365         chain := &Chain{
366                 client: r.client,
367                 tools: &Toolbox{
368                         log:       run.subagent,
369                         chat:      r.memory,
370                         subagents: r.subagents,
371                         workdir:   run.subagent.WorkDir(),
372                 },
373                 request: ModelRequest{
374                         Model:        r.agent.config.Model,
375                         Effort:       run.effort,
376                         Instructions: instructions,
377                         Tools:        promptSubagentTools(),
378                         Agent:        run.subagent.name,
379                 },
380                 take: func() []Message {
381                         return r.take(run)
382                 },
383         }
384
385         return chain.Run(ctx, input)
386 }
387
388 // take returns the messages of Tell that wait for a request of run, and
389 // removes them.
390 func (r *SubagentRuns) take(run *SubagentRun) []Message {
391         r.mutex.Lock()
392         defer r.mutex.Unlock()
393
394         messages := run.pending
395         run.pending = nil
396
397         return messages
398 }
399
400 // SubagentRun is one run of a subagent: the view that its requests get, its
401 // effort, the end of its context, the messages of Tell that wait for a request,
402 // and whether Stop ended it. The mutex of SubagentRuns protects the messages
403 // and the stop.
404 type SubagentRun struct {
405         subagent *Subagent
406         view     []string
407         effort   string
408         cancel   context.CancelFunc
409         pending  []Message
410         stopped  bool
411 }
412
413 // subagentReport returns the report of the subagent name with its replies: the
414 // name in brackets, a new line, the replies, and the zoom that gives its whole
415 // chat.
416 func subagentReport(name string, replies []string) string {
417         return "[" + name + "]\n" + strings.Join(replies, "\n\n") + "\n\nFull chat: zoom(\"" + name + "\")"
418 }