]> git.giacomolli.uk - onechat/blob - subagent_run.go
Shorten user service updates
[onechat] / subagent_run.go
1 package main
2
3 import (
4         "context"
5         "encoding/json"
6         "errors"
7         "fmt"
8         "maps"
9         "os"
10         "path/filepath"
11         "slices"
12         "strings"
13         "sync"
14 )
15
16 const (
17         // subagentNote is the first message of the log of a subagent.
18         subagentNote = "subagent"
19
20         // subagentNoteEffort follows subagentNote when the subagent has its own
21         // effort.
22         subagentNoteEffort = " at effort "
23
24         // subagentWorkFileName is the file of the names of the running
25         // subagents in the directory of an agent.
26         subagentWorkFileName = "work.json"
27 )
28
29 // The statuses of the end of a run, in the work event of its report.
30 const (
31         subagentFinished = "finished"
32         subagentStopped  = "stopped"
33         subagentFailed   = "failed"
34 )
35
36 // errSubagentClosed tells that the runs of the subagents ended with the loop.
37 var errSubagentClosed = errors.New("the daemon stops, so no subagent starts")
38
39 // SubagentRuns runs the subagents of one agent in the background. Each run is a
40 // goroutine with its own context, which only Stop and Close end. A run is one
41 // or more request chains. The first request of each chain holds the view of the
42 // agent at the start of the run, then the state of the turn, then the whole log
43 // of the subagent. A message of Tell that comes during a chain joins its next
44 // request after the tool results. One that comes after the final answer starts
45 // one more chain, which reads the log again. The report of the run goes to
46 // report: the final answer of each chain, or the error of a failed call. Stop
47 // ends one run, and its report says that it stopped. At the stop of the daemon
48 // the runs end with no report.
49 //
50 // work.json in the directory of the agent holds the names of the running
51 // subagents. Each start and each end of a run writes it whole, but Close does
52 // not, so after a stop of the daemon it names the runs that the stop cut, and
53 // Resume goes on with them. The logs alone cannot tell this, because a stop
54 // can cut a run just after its final reply.
55 //
56 // Spawn and Tell split the view with the layout of the agent, which has no
57 // lock. They are tools, so they run in the goroutine of the turns, as each
58 // split of a turn does.
59 type SubagentRuns struct {
60         agent     *Agent
61         memory    *Memory
62         subagents *Subagents
63         layout    *ViewLayout
64         client    ChainClient
65         state     func() string
66         report    func(name, text, status string)
67         note      func(text string) error
68         fail      func(err error)
69         notify    func(Event)
70
71         mutex   sync.Mutex
72         running map[string]*SubagentRun
73         closed  bool
74         workers sync.WaitGroup
75 }
76
77 // Spawn starts one subagent for each task and returns their names at once. It
78 // waits until the view has no pending summary, and gives the view to each
79 // subagent. A nil effort is the effort of the agent.
80 func (r *SubagentRuns) Spawn(ctx context.Context, tasks []string, effort *string) (string, error) {
81         if len(tasks) == 0 {
82                 return "", errors.New("no tasks")
83         }
84
85         r.mutex.Lock()
86         closed := r.closed
87         r.mutex.Unlock()
88
89         if closed {
90                 return "", errSubagentClosed
91         }
92
93         note := subagentNote
94         runEffort := r.agent.config.Effort
95         if effort != nil && !slices.Contains(promptEfforts, *effort) {
96                 return "", fmt.Errorf("no effort %q: one of %s", *effort, strings.Join(promptEfforts, ", "))
97         }
98
99         if effort != nil && *effort != runEffort {
100                 note += subagentNoteEffort + *effort
101                 runEffort = *effort
102         }
103
104         err := r.memory.Settle(ctx)
105         if err != nil {
106                 return "", fmt.Errorf("wait for the summaries: %w", err)
107         }
108
109         view, err := r.layout.Split(r.memory.ViewLines(nil))
110         if err != nil {
111                 return "", err
112         }
113
114         names := make([]string, 0, len(tasks))
115         for _, task := range tasks {
116                 name, err := r.create(ctx, note, task, view, runEffort)
117                 if err != nil && len(names) > 0 {
118                         return "", fmt.Errorf("started %s, then: %w", strings.Join(names, ", "), err)
119                 }
120
121                 if err != nil {
122                         return "", err
123                 }
124
125                 names = append(names, name)
126         }
127
128         return "Started " + strings.Join(names, ", ") + ". Each one's report will reach you as a message when it finishes.", nil
129 }
130
131 // Tell sends message to the subagent name. A running subagent reads it between
132 // its tool calls, and its report answers it. A finished subagent goes on from
133 // its log with a new run, with the view of now and the effort of its note.
134 func (r *SubagentRuns) Tell(ctx context.Context, name, message string) (string, error) {
135         r.mutex.Lock()
136         run, found := r.running[name]
137         if found {
138                 defer r.mutex.Unlock()
139
140                 told, err := run.subagent.Append("user", message)
141                 if err != nil {
142                         return "", err
143                 }
144
145                 run.pending = append(run.pending, told)
146
147                 return "Sent. " + name + " reads it between its tool calls, and its report answers it.", nil
148         }
149
150         closed := r.closed
151         r.mutex.Unlock()
152
153         if closed {
154                 return "", errSubagentClosed
155         }
156
157         subagent, err := r.subagents.Get(ctx, name)
158         if err != nil {
159                 return "", err
160         }
161
162         effort, err := r.effort(subagent)
163         if err != nil {
164                 return "", err
165         }
166
167         err = r.memory.Settle(ctx)
168         if err != nil {
169                 return "", fmt.Errorf("wait for the summaries: %w", err)
170         }
171
172         view, err := r.layout.Split(r.memory.ViewLines(nil))
173         if err != nil {
174                 return "", err
175         }
176
177         _, err = subagent.Append("user", message)
178         if err == nil {
179                 err = subagent.Flush(ctx)
180         }
181
182         if err != nil {
183                 return "", err
184         }
185
186         err = r.start(ctx, &SubagentRun{
187                 subagent: subagent,
188                 view:     view,
189                 effort:   effort,
190         })
191         if err != nil {
192                 return "", err
193         }
194
195         return "Sent. " + name + "'s reply will reach you as a message.", nil
196 }
197
198 // Stop ends the run of the subagent name and tells if it ran. The chat gets the
199 // note "Stopped by the user: NAME." under the mutex, so the note comes before
200 // the report of the run.
201 func (r *SubagentRuns) Stop(name string) (bool, error) {
202         r.mutex.Lock()
203         defer r.mutex.Unlock()
204
205         run, found := r.running[name]
206         if !found {
207                 return false, nil
208         }
209
210         err := r.note("Stopped by the user: " + name + ".")
211         if err != nil {
212                 return true, err
213         }
214
215         run.stopped = true
216         run.cancel()
217
218         return true, nil
219 }
220
221 // Resume goes on with the runs that a stop of the daemon cut, as work.json
222 // names them. The chat gets the note "The daemon restarted, cutting NAMES;
223 // nothing was lost: go on.", and each cut subagent gets the same text as a
224 // message of Tell. A subagent that cannot go on gets a failed report.
225 func (r *SubagentRuns) Resume(ctx context.Context) error {
226         names, err := r.load()
227         if err != nil || len(names) == 0 {
228                 return err
229         }
230
231         text := "The daemon restarted, cutting " + strings.Join(names, ", ") + "; nothing was lost: go on."
232         err = r.note(text)
233         if err != nil {
234                 return err
235         }
236
237         for _, name := range names {
238                 _, err = r.Tell(ctx, name, text)
239                 if err != nil {
240                         r.report(name, subagentReport(name, []string{"failed: " + err.Error()}), subagentFailed)
241                 }
242         }
243
244         // A subagent that cannot go on does not run, so work.json no longer
245         // names it, and the next start does not try it again.
246         r.mutex.Lock()
247         defer r.mutex.Unlock()
248
249         return r.save()
250 }
251
252 // Close ends every run and waits for them. No subagent starts after Close.
253 func (r *SubagentRuns) Close() {
254         r.mutex.Lock()
255         r.closed = true
256         for _, run := range r.running {
257                 run.cancel()
258         }
259         r.mutex.Unlock()
260
261         r.workers.Wait()
262 }
263
264 // create makes a new subagent with its note, its task, and its work directory,
265 // and starts its run.
266 func (r *SubagentRuns) create(ctx context.Context, note, task string, view []string, effort string) (string, error) {
267         subagent, err := r.subagents.Create(ctx)
268         if err != nil {
269                 return "", err
270         }
271
272         for _, message := range [][2]string{{"note", note}, {"user", task}} {
273                 _, err = subagent.Append(message[0], message[1])
274                 if err != nil {
275                         return "", err
276                 }
277         }
278
279         err = subagent.Flush(ctx)
280         if err != nil {
281                 return "", err
282         }
283
284         err = os.MkdirAll(subagent.WorkDir(), 0o700)
285         if err != nil {
286                 return "", fmt.Errorf("create work directory: %w", err)
287         }
288
289         return subagent.name, r.start(ctx, &SubagentRun{
290                 subagent: subagent,
291                 view:     view,
292                 effort:   effort,
293         })
294 }
295
296 // effort returns the effort of subagent from the note at the start of its log:
297 // the effort of the agent, or the effort that the note names.
298 func (r *SubagentRuns) effort(subagent *Subagent) (string, error) {
299         note, err := subagent.First()
300         if err != nil {
301                 return "", err
302         }
303
304         effort, own := strings.CutPrefix(note.Text, subagentNote+subagentNoteEffort)
305         switch {
306         case note.Kind != "note":
307         case own:
308                 return effort, nil
309         case note.Text == subagentNote:
310                 return r.agent.config.Effort, nil
311         }
312
313         return "", fmt.Errorf("the log of %s does not start with a subagent note", subagent.name)
314 }
315
316 // start runs run in the background. Its context keeps the values of ctx, but
317 // only Stop and Close end it.
318 func (r *SubagentRuns) start(ctx context.Context, run *SubagentRun) error {
319         ctx, run.cancel = context.WithCancel(context.WithoutCancel(ctx))
320
321         r.mutex.Lock()
322         _, found := r.running[run.subagent.name]
323         if r.closed || found {
324                 r.mutex.Unlock()
325                 run.cancel()
326
327                 if found {
328                         return fmt.Errorf("%s runs already", run.subagent.name)
329                 }
330
331                 return errSubagentClosed
332         }
333
334         r.running[run.subagent.name] = run
335         err := r.save()
336         if err != nil {
337                 delete(r.running, run.subagent.name)
338                 r.mutex.Unlock()
339                 run.cancel()
340
341                 return err
342         }
343
344         r.workers.Add(1)
345         r.mutex.Unlock()
346
347         r.notify(Event{
348                 Kind: "subagent",
349                 Text: run.subagent.name,
350         })
351
352         go r.run(ctx, run)
353
354         return nil
355 }
356
357 // load reads the names of work.json. A missing file names none.
358 func (r *SubagentRuns) load() ([]string, error) {
359         data, err := os.ReadFile(r.workPath())
360         if errors.Is(err, os.ErrNotExist) {
361                 return nil, nil
362         }
363
364         if err != nil {
365                 return nil, fmt.Errorf("read running subagents: %w", err)
366         }
367
368         var file SubagentWorkFile
369         err = json.Unmarshal(data, &file)
370         if err != nil {
371                 return nil, fmt.Errorf("decode running subagents: %w", err)
372         }
373
374         return file.Running, nil
375 }
376
377 // save writes the names of the running subagents to work.json. The caller
378 // holds the mutex.
379 func (r *SubagentRuns) save() error {
380         file := SubagentWorkFile{
381                 Running: append([]string{}, slices.Sorted(maps.Keys(r.running))...),
382         }
383
384         data, err := json.Marshal(file)
385         if err != nil {
386                 return fmt.Errorf("encode running subagents: %w", err)
387         }
388
389         err = writeFileAtomic(r.workPath(), append(data, '\n'))
390         if err != nil {
391                 return fmt.Errorf("save running subagents: %w", err)
392         }
393
394         return nil
395 }
396
397 func (r *SubagentRuns) workPath() string {
398         return filepath.Join(r.agent.dir, subagentWorkFileName)
399 }
400
401 // run runs the chains of run and reports its end, unless Close ended it. A
402 // chain follows while messages wait after the final answer of the chain before
403 // it, and no chain failed. A chain that Stop ends gives the reply "stopped".
404 func (r *SubagentRuns) run(ctx context.Context, run *SubagentRun) {
405         defer r.workers.Done()
406         defer run.cancel()
407
408         var replies []string
409         stopped := false
410         status := subagentFinished
411         for more := true; more; {
412                 reply, err := r.chain(ctx, run)
413
414                 r.mutex.Lock()
415                 stopped = run.stopped
416                 switch {
417                 case err != nil && stopped:
418                         reply = "stopped"
419                 case err != nil:
420                         reply = "failed: " + err.Error()
421                         status = subagentFailed
422                 }
423
424                 more = err == nil && ctx.Err() == nil && len(run.pending) > 0
425                 run.pending = nil
426                 var saveErr error
427                 if !more {
428                         delete(r.running, run.subagent.name)
429                         if !r.closed {
430                                 saveErr = r.save()
431                         }
432                 }
433                 r.mutex.Unlock()
434
435                 if saveErr != nil {
436                         r.fail(saveErr)
437                 }
438
439                 replies = append(replies, reply)
440         }
441
442         if ctx.Err() != nil && !stopped {
443                 return
444         }
445
446         if stopped {
447                 status = subagentStopped
448         }
449
450         r.report(run.subagent.name, subagentReport(run.subagent.name, replies), status)
451 }
452
453 // chain runs one request chain of run and returns its final answer.
454 func (r *SubagentRuns) chain(ctx context.Context, run *SubagentRun) (string, error) {
455         instructions, err := r.agent.SubagentPrompt()
456         if err != nil {
457                 return "", err
458         }
459
460         log, err := run.subagent.Show()
461         if err != nil {
462                 return "", err
463         }
464
465         input := make([]any, 0, len(run.view)+2)
466         for _, text := range run.view {
467                 input = append(input, modelUserMessage(text))
468         }
469
470         input = append(input, modelUserMessage(r.state()), modelUserMessage(log))
471         chain := &Chain{
472                 client: r.client,
473                 tools: &Toolbox{
474                         log:       run.subagent,
475                         chat:      r.memory,
476                         subagents: r.subagents,
477                         workdir:   run.subagent.WorkDir(),
478                 },
479                 request: ModelRequest{
480                         Model:        r.agent.config.Model,
481                         Effort:       run.effort,
482                         Instructions: instructions,
483                         Tools:        promptSubagentTools(),
484                         Agent:        run.subagent.name,
485                 },
486                 take: func() []Message {
487                         return r.take(run)
488                 },
489         }
490
491         return chain.Run(ctx, input)
492 }
493
494 // take returns the messages of Tell that wait for a request of run, and
495 // removes them.
496 func (r *SubagentRuns) take(run *SubagentRun) []Message {
497         r.mutex.Lock()
498         defer r.mutex.Unlock()
499
500         messages := run.pending
501         run.pending = nil
502
503         return messages
504 }
505
506 // SubagentRun is one run of a subagent: the view that its requests get, its
507 // effort, the end of its context, the messages of Tell that wait for a request,
508 // and whether Stop ended it. The mutex of SubagentRuns protects the messages
509 // and the stop.
510 type SubagentRun struct {
511         subagent *Subagent
512         view     []string
513         effort   string
514         cancel   context.CancelFunc
515         pending  []Message
516         stopped  bool
517 }
518
519 // SubagentWorkFile is the content of work.json.
520 type SubagentWorkFile struct {
521         Running []string `json:"running"`
522 }
523
524 // subagentReport returns the report of the subagent name with its replies: the
525 // name in brackets, a new line, the replies, and the zoom that gives its whole
526 // chat.
527 func subagentReport(name string, replies []string) string {
528         return "[" + name + "]\n" + strings.Join(replies, "\n\n") + "\n\nFull chat: zoom(\"" + name + "\")"
529 }