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