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